Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffaab15a4 |
@@ -1,43 +0,0 @@
|
||||
COMPOSE_PROJECT_NAME=dhv2
|
||||
|
||||
DB_NAME=dhv2
|
||||
DB_OWNER_USER=dhv2_owner
|
||||
DB_OWNER_PASSWORD=CHANGE_ME
|
||||
DB_APP_USER=dhv2_app
|
||||
DB_APP_PASSWORD=CHANGE_ME
|
||||
|
||||
API_PORT=3000
|
||||
WEB_HOST_PORT=8182
|
||||
API_HOST_PORT=3101
|
||||
|
||||
WEB_ORIGIN=https://dhv2.korexlabs.com
|
||||
JWT_ACCESS_SECRET=CHANGE_ME_WITH_AT_LEAST_64_RANDOM_CHARACTERS
|
||||
REFRESH_TOKEN_PEPPER=CHANGE_ME_WITH_ANOTHER_64_RANDOM_CHARACTERS
|
||||
ACCESS_TOKEN_TTL_SECONDS=900
|
||||
REFRESH_TOKEN_TTL_SECONDS=604800
|
||||
AUTH_MAX_LOGIN_ATTEMPTS=5
|
||||
AUTH_LOCKOUT_SECONDS=900
|
||||
ACCESS_COOKIE_NAME=dhv2_access
|
||||
REFRESH_COOKIE_NAME=dhv2_refresh
|
||||
CSRF_COOKIE_NAME=dhv2_csrf
|
||||
|
||||
INSPECTION_SIGNATURE_ROOT=/app/storage/asset-media/inspection-signatures
|
||||
INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
|
||||
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
|
||||
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf
|
||||
|
||||
# Enlace público de un solo uso para firma/manifestación de empresa.
|
||||
# En producción: https://dhv2.korexlabs.com/firma-acta
|
||||
# En entorno de prueba: https://prueba.dhv2.korexlabs.com/firma-acta
|
||||
COMPANY_SIGNATURE_PUBLIC_BASE_URL=https://dhv2.korexlabs.com/firma-acta
|
||||
|
||||
# Clave maestra de 32 bytes (64 hex o base64) para cifrar la contraseña SMTP guardada por Superadmin.
|
||||
SMTP_SETTINGS_MASTER_KEY=CHANGE_ME_WITH_32_RANDOM_BYTES
|
||||
|
||||
# Fallback SMTP. Se usa sólo hasta que Superadmin guarde una configuración en la base de datos.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
MAIL_FROM=
|
||||
@@ -1,143 +0,0 @@
|
||||
name: Android CI / RC
|
||||
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/f6-1*'
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- 'api-v3/src/**'
|
||||
- '.github/workflows/android.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- 'api-v3/src/**'
|
||||
- '.github/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dhv2-android-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Android · lint, tests, debug APK, release compile
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Android API 36
|
||||
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
|
||||
|
||||
- name: Gradle 8.13
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: '8.13'
|
||||
|
||||
- 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: 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
|
||||
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-${{ 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
|
||||
@@ -1,269 +0,0 @@
|
||||
name: DH V2 CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dhv2-ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API · typecheck, tests, build
|
||||
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
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
|
||||
web:
|
||||
name: WEB · typecheck, build
|
||||
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
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- name: F3.1 WEB contract
|
||||
run: bash ../scripts/check-f3-1-web-contract.sh
|
||||
- run: npm run build
|
||||
|
||||
contract:
|
||||
name: Docker / scripts contract
|
||||
runs-on: ubuntu-latest
|
||||
needs: [api, web]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate shell scripts
|
||||
run: |
|
||||
while IFS= read -r -d '' script; do
|
||||
bash -n "$script"
|
||||
done < <(find scripts -type f -name '*.sh' -print0)
|
||||
- name: Validate deploy preflight parity
|
||||
run: |
|
||||
grep -Fq -- '$STAGE/docker-compose.yml:/docker-compose.yml:ro' scripts/deploy-github.sh
|
||||
grep -Fq -- '$STAGE/web-v2:/web-v2:ro' scripts/deploy-github.sh
|
||||
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
|
||||
- name: Validate Compose
|
||||
run: docker compose --env-file .env.example config >/dev/null
|
||||
- name: Rehearse migrations and real API startup on clean PostGIS
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cleanup() {
|
||||
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
|
||||
docker compose --env-file .env.example up -d db
|
||||
|
||||
# Historical production reset is a one-shot migration that expects the
|
||||
# production admin. Prove the clean chain reaches that exact guard,
|
||||
# mark only that historical reset as applied, then continue the chain.
|
||||
bootstrap_log="$(mktemp)"
|
||||
set +e
|
||||
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
|
||||
bootstrap_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$bootstrap_status" -eq 0 ]; then
|
||||
echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
|
||||
echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2
|
||||
exit 1
|
||||
}
|
||||
rm -f "$bootstrap_log"
|
||||
|
||||
docker compose --env-file .env.example exec -T db \
|
||||
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||
DO $$
|
||||
DECLARE reset_rows integer;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO reset_rows
|
||||
FROM typeorm_migrations
|
||||
WHERE name = 'ResetProductionOperationalData1788652800000';
|
||||
IF reset_rows <> 0 THEN
|
||||
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
|
||||
END IF;
|
||||
INSERT INTO typeorm_migrations ("timestamp", name)
|
||||
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
docker compose --env-file .env.example --profile tools run --rm migrate
|
||||
|
||||
# F5.1 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 $$
|
||||
DECLARE
|
||||
f5_migrations integer;
|
||||
domain_assets integer;
|
||||
audits integer;
|
||||
applicability integer;
|
||||
territory_sources integer;
|
||||
presentation_sources integer;
|
||||
source_installations integer;
|
||||
source_subinstallations integer;
|
||||
source_findings integer;
|
||||
department_types integer;
|
||||
area_department_rules integer;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO f5_migrations
|
||||
FROM typeorm_migrations
|
||||
WHERE name IN (
|
||||
'F5InventoryPhysicalInstance1790087100000',
|
||||
'F5CanonicalInventoryHierarchy1790087150000',
|
||||
'F5AuthoritativeTerritory1790087200000',
|
||||
'F5OperationalContextCompatibility1790087250000',
|
||||
'F5AuthoritativeInventoryCatalog1790087300000',
|
||||
'F51CleanManualInventory1790087400000'
|
||||
);
|
||||
IF f5_migrations <> 6 THEN
|
||||
RAISE EXCEPTION 'Expected 6 F5/F5.1 migrations, got %', f5_migrations;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO domain_assets FROM assets;
|
||||
IF domain_assets <> 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 '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 'Clean migration rehearsal must contain 0 finding applicability links, got %', applicability;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO territory_sources
|
||||
FROM source_documents
|
||||
WHERE document_number='DH-F5-TERRITORY';
|
||||
IF territory_sources <> 0 THEN
|
||||
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) 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 'F6.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO source_findings
|
||||
FROM finding_catalog_items item
|
||||
JOIN finding_categories category ON category.id=item.category_id
|
||||
WHERE lower(category.code)='f5model' AND item.is_active=true;
|
||||
IF source_findings <> 177 THEN
|
||||
RAISE EXCEPTION 'F6.1 must preserve finding master catalog, got %', source_findings;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO department_types
|
||||
FROM asset_types
|
||||
WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true;
|
||||
IF department_types <> 1 THEN
|
||||
RAISE EXCEPTION 'Expected one active root Departamento type, got %', department_types;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO area_department_rules
|
||||
FROM asset_type_parent_rules rule
|
||||
JOIN asset_types child ON child.id=rule.child_type_id
|
||||
JOIN asset_types parent ON parent.id=rule.parent_type_id
|
||||
WHERE lower(child.code)='area' AND lower(parent.code)='departamento';
|
||||
IF area_department_rules <> 1 THEN
|
||||
RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
# 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: F6.1 migration chain is not idempotent." >&2
|
||||
cat "$rerun_log" >&2
|
||||
exit 1
|
||||
}
|
||||
rm -f "$rerun_log"
|
||||
|
||||
# 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: |
|
||||
set -Eeuo pipefail
|
||||
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
|
||||
docker build --target builder -t "$image" api-v3
|
||||
docker run --rm \
|
||||
-v "$PWD/api-v3/test:/app/test:ro" \
|
||||
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||
-v "$PWD/docker-compose.yml:/docker-compose.yml:ro" \
|
||||
-v "$PWD/web-v2:/web-v2:ro" \
|
||||
-v "$PWD/android-app:/android-app:ro" \
|
||||
"$image" npm test
|
||||
docker image rm "$image" >/dev/null 2>&1 || true
|
||||
- name: Build production images
|
||||
run: docker compose --env-file .env.example build api migrate web
|
||||
@@ -1,136 +0,0 @@
|
||||
name: F2.2 Integration CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'feature/f2-2*'
|
||||
paths:
|
||||
- 'api-v3/**'
|
||||
- 'web-v2/**'
|
||||
- 'scripts/**'
|
||||
- 'docker-compose.yml'
|
||||
- '.github/workflows/f2-2-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API · typecheck, tests, build
|
||||
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
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
|
||||
migrations:
|
||||
name: DB · migrations on PostgreSQL 16/PostGIS
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_USER: dhv2_owner
|
||||
POSTGRES_PASSWORD: owner_test_password
|
||||
POSTGRES_DB: dhv2_ci
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U dhv2_owner -d dhv2_ci"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
env:
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 5432
|
||||
DB_NAME: dhv2_ci
|
||||
DB_MIGRATION_USER: dhv2_owner
|
||||
DB_MIGRATION_PASSWORD: owner_test_password
|
||||
DB_APP_USER: dhv2_app
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api-v3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: api-v3/package-lock.json
|
||||
- name: PostgreSQL client
|
||||
run: |
|
||||
command -v psql >/dev/null || {
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-client
|
||||
}
|
||||
- name: Prepare roles and extensions
|
||||
env:
|
||||
PGPASSWORD: owner_test_password
|
||||
run: |
|
||||
psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U dhv2_owner -d dhv2_ci <<'SQL'
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
|
||||
CREATE ROLE dhv2_app LOGIN PASSWORD 'app_test_password';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
GRANT CONNECT ON DATABASE dhv2_ci TO dhv2_app;
|
||||
GRANT USAGE ON SCHEMA public TO dhv2_app;
|
||||
|
||||
-- This reset was a one-time late production operation. It is marked as
|
||||
-- executed on the clean CI database so the historical schema can be
|
||||
-- constructed chronologically without running that production-only wipe.
|
||||
CREATE TABLE IF NOT EXISTS typeorm_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
timestamp bigint NOT NULL,
|
||||
name varchar NOT NULL
|
||||
);
|
||||
INSERT INTO typeorm_migrations (timestamp,name)
|
||||
VALUES (1788652800000,'ResetProductionOperationalData1788652800000');
|
||||
SQL
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Run every pending migration
|
||||
run: npm run migration:run
|
||||
- name: Assert no pending migrations
|
||||
run: |
|
||||
npm run migration:show | tee /tmp/migrations.txt
|
||||
grep -Fq 'Pending migrations: no' /tmp/migrations.txt
|
||||
- name: Assert F2.2.1 reference import
|
||||
env:
|
||||
PGPASSWORD: owner_test_password
|
||||
run: |
|
||||
test "$(psql -At -h 127.0.0.1 -U dhv2_owner -d dhv2_ci -c "SELECT count(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.data_origin='IMPORT' AND a.source_name='Tablas de yacimiento(1).xlsx' AND lower(t.code)='yacimiento'")" = "230"
|
||||
test "$(psql -At -h 127.0.0.1 -U dhv2_owner -d dhv2_ci -c "SELECT count(*) FROM assets WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL)")" = "0"
|
||||
test "$(psql -At -h 127.0.0.1 -U dhv2_owner -d dhv2_ci -c "SELECT count(*) FROM source_documents WHERE document_number IN ('DH-F221-TERRITORIO','DH-F221-APP')")" = "2"
|
||||
|
||||
web:
|
||||
name: WEB · typecheck, build
|
||||
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
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run build
|
||||
@@ -1,62 +0,0 @@
|
||||
name: F2.3 Field Finding CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'feature/f2-3*'
|
||||
paths:
|
||||
- 'api-v3/**'
|
||||
- '.github/workflows/f2-3-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api-v3
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Node 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: api-v3/package-lock.json
|
||||
- name: Install
|
||||
run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck 2>&1 | tee typecheck.log
|
||||
- name: Upload TypeScript diagnostic
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: f2-3-typecheck-diagnostic
|
||||
path: api-v3/typecheck.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
- name: Tests
|
||||
run: npm test 2>&1 | tee test.log
|
||||
- name: Upload test diagnostic
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: f2-3-test-diagnostic
|
||||
path: api-v3/test.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
- name: Build
|
||||
run: npm run build 2>&1 | tee build.log
|
||||
- name: Upload build diagnostic
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: f2-3-build-diagnostic
|
||||
path: api-v3/build.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
@@ -1,205 +0,0 @@
|
||||
name: F3.1 Inventory CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'feature/f3-1*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api-v3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: api-v3/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck 2>&1 | tee typecheck.log
|
||||
- name: Tests
|
||||
run: npm test 2>&1 | tee test.log
|
||||
- name: Build
|
||||
run: npm run build 2>&1 | tee build.log
|
||||
- name: Diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: f3-1-api-diagnostics
|
||||
path: |
|
||||
api-v3/typecheck.log
|
||||
api-v3/test.log
|
||||
api-v3/build.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
migrations:
|
||||
name: DB · F3.1 on PostgreSQL 16/PostGIS
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_USER: dhv2_owner
|
||||
POSTGRES_PASSWORD: owner_test_password
|
||||
POSTGRES_DB: dhv2_ci
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U dhv2_owner -d dhv2_ci"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
env:
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 5432
|
||||
DB_NAME: dhv2_ci
|
||||
DB_MIGRATION_USER: dhv2_owner
|
||||
DB_MIGRATION_PASSWORD: owner_test_password
|
||||
DB_APP_USER: dhv2_app
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api-v3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: api-v3/package-lock.json
|
||||
- name: PostgreSQL client
|
||||
run: |
|
||||
command -v psql >/dev/null || {
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-client
|
||||
}
|
||||
- name: Prepare roles and extensions
|
||||
env:
|
||||
PGPASSWORD: owner_test_password
|
||||
run: |
|
||||
psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U dhv2_owner -d dhv2_ci <<'SQL'
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
|
||||
CREATE ROLE dhv2_app LOGIN PASSWORD 'app_test_password';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
GRANT CONNECT ON DATABASE dhv2_ci TO dhv2_app;
|
||||
GRANT USAGE ON SCHEMA public TO dhv2_app;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS typeorm_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
timestamp bigint NOT NULL,
|
||||
name varchar NOT NULL
|
||||
);
|
||||
INSERT INTO typeorm_migrations (timestamp,name)
|
||||
VALUES (1788652800000,'ResetProductionOperationalData1788652800000');
|
||||
SQL
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- name: Run every pending migration
|
||||
run: npm run migration:run
|
||||
- name: Assert no pending migrations
|
||||
run: |
|
||||
npm run migration:show | tee /tmp/migrations.txt
|
||||
grep -Fq 'Pending migrations: no' /tmp/migrations.txt
|
||||
- name: Assert F3.1 structure, merges and Inspector profile
|
||||
env:
|
||||
PGPASSWORD: owner_test_password
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
|
||||
query() {
|
||||
psql -At -v ON_ERROR_STOP=1 \
|
||||
-h 127.0.0.1 \
|
||||
-U dhv2_owner \
|
||||
-d dhv2_ci \
|
||||
-c "$1"
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local expected="$1"
|
||||
local sql="$2"
|
||||
local actual
|
||||
actual="$(query "$sql")"
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "ASSERT FAILED"
|
||||
echo "SQL: $sql"
|
||||
echo "Expected: $expected"
|
||||
echo "Actual: $actual"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_eq 15 "SELECT count(*) FROM inventory_families WHERE level='INSTALLATION' AND is_active=true"
|
||||
assert_eq 124 "SELECT count(*) FROM inventory_families WHERE level='SUBINSTALLATION' AND is_active=true"
|
||||
assert_eq 16 "SELECT count(*) FROM inventory_families WHERE source_reference LIKE 'SYSTEM:F3.1:OTHER%'"
|
||||
assert_eq 1 "SELECT count(*) FROM finding_categories WHERE lower(code)='app26r2' AND is_active=true"
|
||||
assert_eq 1 "SELECT count(*) FROM finding_categories WHERE lower(code)='app26' AND is_active=false"
|
||||
|
||||
assert_eq 1 "SELECT count(*) FROM information_schema.tables WHERE table_name='asset_merges'"
|
||||
assert_eq 1 "SELECT count(*) FROM pg_trigger WHERE tgname='trg_asset_merges_append_only' AND NOT tgisinternal"
|
||||
assert_eq 1 "SELECT count(*) FROM information_schema.tables WHERE table_name='finding_catalog_item_merges'"
|
||||
assert_eq 1 "SELECT count(*) FROM pg_trigger WHERE tgname='trg_finding_catalog_item_merges_append_only' AND NOT tgisinternal"
|
||||
|
||||
assert_eq 4 "SELECT count(*) FROM information_schema.columns WHERE table_name='users' AND column_name IN ('dni','phone','job_title','employee_number')"
|
||||
assert_eq 2 "SELECT count(*) FROM pg_trigger WHERE tgname IN ('trg_user_roles_inspector_email','trg_users_inspector_email') AND NOT tgisinternal"
|
||||
assert_eq 1 "SELECT count(*) FROM information_schema.columns WHERE table_name='inspection_document_deliveries' AND column_name='recipient_user_id'"
|
||||
assert_eq 1 "SELECT count(*) FROM pg_constraint WHERE conname='chk_inspection_document_deliveries_recipient_kind' AND pg_get_constraintdef(oid) LIKE '%INSPECTOR%'"
|
||||
assert_eq 1 "SELECT count(*) FROM pg_constraint WHERE conname='fk_inspection_document_deliveries_recipient_user'"
|
||||
|
||||
assert_eq 230 "SELECT count(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.data_origin='IMPORT' AND a.source_name='Tablas de yacimiento(1).xlsx' AND lower(t.code)='yacimiento'"
|
||||
assert_eq 0 "SELECT count(*) FROM assets WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL)"
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web-v2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: web-v2/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck 2>&1 | tee typecheck.log
|
||||
- name: Build
|
||||
run: npm run build 2>&1 | tee build.log
|
||||
- name: Diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: f3-1-web-diagnostics
|
||||
path: |
|
||||
web-v2/typecheck.log
|
||||
web-v2/build.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
docker:
|
||||
needs: [api, web, migrations]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate Compose
|
||||
run: docker compose --env-file .env.example config >/dev/null
|
||||
- name: Build production images
|
||||
run: docker compose --env-file .env.example build api migrate web
|
||||
@@ -1,80 +0,0 @@
|
||||
name: F3.2 Multi-Acta CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'feature/f3-2*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API · F3.2
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api-v3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: api-v3/package-lock.json
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
- name: Tests
|
||||
run: npm test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
web:
|
||||
name: WEB · regression
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web-v2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: web-v2/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- name: F3.1 structural WEB contract
|
||||
run: bash ../scripts/check-f3-1-web-contract.sh
|
||||
- run: npm run build
|
||||
|
||||
deploy-preflight:
|
||||
name: VPS-equivalent preflight / Docker
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [api, web]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Validate shell scripts
|
||||
run: |
|
||||
while IFS= read -r -d '' script; do
|
||||
bash -n "$script"
|
||||
done < <(find scripts -type f -name '*.sh' -print0)
|
||||
- name: Validate Compose
|
||||
run: docker compose --env-file .env.example config >/dev/null
|
||||
- name: VPS-equivalent isolated API tests
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
image="dhv2-api:f3-2-preflight-${GITHUB_SHA::12}"
|
||||
docker build --target builder -t "$image" api-v3
|
||||
docker run --rm \
|
||||
-v "$PWD/api-v3/test:/app/test:ro" \
|
||||
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||
"$image" npm test
|
||||
docker image rm "$image" >/dev/null 2>&1 || true
|
||||
- name: Build production images
|
||||
run: docker compose --env-file .env.example build api migrate web
|
||||
@@ -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
|
||||
@@ -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
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
# Secrets / environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Dependencies / builds
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/.next/
|
||||
**/.cache/
|
||||
**/.vite/
|
||||
**/coverage/
|
||||
|
||||
# Backups / exports
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.dump
|
||||
*.sql
|
||||
*.bak
|
||||
*.bak-*
|
||||
*.backup
|
||||
|
||||
# Runtime / logs
|
||||
*.log
|
||||
*.pid
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# OS / editors
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Private keys / certificates
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
id_rsa
|
||||
id_ed25519
|
||||
*_github
|
||||
*_github.pub
|
||||
|
||||
# TypeScript generated metadata
|
||||
*.tsbuildinfo
|
||||
|
||||
# Generated from web-v2/vite.config.ts
|
||||
web-v2/vite.config.js
|
||||
web-v2/vite.config.d.ts
|
||||
@@ -1,31 +0,0 @@
|
||||
# DH Inspección V2
|
||||
|
||||
Repositorio del sistema DH Inspección V2.
|
||||
|
||||
## Componentes
|
||||
|
||||
- `api-v3/`: API NestJS/TypeORM y contratos de dominio.
|
||||
- `web-v2/`: aplicación WEB React/Vite.
|
||||
- `android-app/`: aplicación Android para trabajo de campo.
|
||||
- `scripts/`: utilidades operativas, preflight y despliegue.
|
||||
- `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.
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,80 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.korexlabs.dhinspeccion"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 25
|
||||
versionName = "0.16.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// La firma de release NO se redefine aquí. Se conservará la clave histórica.
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions.jvmTarget = "17"
|
||||
|
||||
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.08.01")
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.17.0")
|
||||
implementation("androidx.activity:activity-compose:1.10.1")
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.9")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.2")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
|
||||
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.google.android.gms:play-services-location:21.3.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
# DH Inspección V2. Las reglas se ampliarán cuando se habilite minificación de release.
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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_exact"
|
||||
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DHInspeccion"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.korexlabs.dhinspeccion
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.korexlabs.dhinspeccion.ui.DhRoot
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val model: MainViewModel = viewModel()
|
||||
DhRoot(model, this@MainActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,652 +0,0 @@
|
||||
package com.korexlabs.dhinspeccion
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
|
||||
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
|
||||
import com.korexlabs.dhinspeccion.data.DhRepository
|
||||
import com.korexlabs.dhinspeccion.data.FieldAssetDetail
|
||||
import com.korexlabs.dhinspeccion.data.FieldFindingEvidence
|
||||
import com.korexlabs.dhinspeccion.data.FieldFindingItem
|
||||
import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
|
||||
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
|
||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||
import com.korexlabs.dhinspeccion.data.FieldType
|
||||
import com.korexlabs.dhinspeccion.data.MobileActClosure
|
||||
import com.korexlabs.dhinspeccion.data.MobileActDetail
|
||||
import com.korexlabs.dhinspeccion.data.MobileActSummary
|
||||
import com.korexlabs.dhinspeccion.data.MobileActsRepository
|
||||
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
|
||||
import com.korexlabs.dhinspeccion.data.StoredSession
|
||||
import com.korexlabs.dhinspeccion.data.VisitDetail
|
||||
import com.korexlabs.dhinspeccion.data.VisitSummary
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = DhRepository(application)
|
||||
private val findingsRepository = FieldFindingsRepository(application)
|
||||
private val actsRepository = MobileActsRepository(application)
|
||||
|
||||
var session: StoredSession? by mutableStateOf(repository.currentSession())
|
||||
private set
|
||||
var busy by mutableStateOf(false)
|
||||
private set
|
||||
var error: String? by mutableStateOf(null)
|
||||
private set
|
||||
var notice: String? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var visits: List<VisitSummary> by mutableStateOf(emptyList())
|
||||
private set
|
||||
var visit: VisitDetail? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
|
||||
private set
|
||||
var inventoryParentId: String? by mutableStateOf(null)
|
||||
private set
|
||||
var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
|
||||
private set
|
||||
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var acts: List<MobileActSummary> by mutableStateOf(emptyList())
|
||||
private set
|
||||
var selectedAct: MobileActDetail? by mutableStateOf(null)
|
||||
private set
|
||||
var actClosure: MobileActClosure? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
var fieldFindingOptions: FieldFindingOptionsResponse? by mutableStateOf(null)
|
||||
private set
|
||||
var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null)
|
||||
private set
|
||||
var fieldFindingEvidence: Map<String, List<FieldFindingEvidence>> by mutableStateOf(emptyMap())
|
||||
private set
|
||||
|
||||
init {
|
||||
if (session != null) loadVisits()
|
||||
}
|
||||
|
||||
fun clearMessages() {
|
||||
error = null
|
||||
notice = null
|
||||
}
|
||||
|
||||
fun login(identifier: String, password: String) {
|
||||
if (identifier.isBlank() || password.isBlank()) {
|
||||
error = "Ingresá usuario y contraseña."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
session = repository.login(identifier, password)
|
||||
notice = "Sesión iniciada."
|
||||
loadVisitsInternal()
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.logout() }
|
||||
session = null
|
||||
visits = emptyList()
|
||||
visit = null
|
||||
inventory = emptyList()
|
||||
inventoryParentId = null
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearActState()
|
||||
clearFindingState()
|
||||
}
|
||||
}
|
||||
|
||||
fun loadVisits() = launchBusy { loadVisitsInternal() }
|
||||
|
||||
private suspend fun loadVisitsInternal() {
|
||||
visits = repository.visits().data
|
||||
}
|
||||
|
||||
fun openVisit(id: String) = launchBusy {
|
||||
visit = repository.visit(id)
|
||||
inventory = emptyList()
|
||||
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearFindingState()
|
||||
loadActsInternal(id, selectDraft = true)
|
||||
}
|
||||
|
||||
fun closeVisitView() {
|
||||
visit = null
|
||||
inventory = emptyList()
|
||||
inventoryParentId = null
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearActState()
|
||||
clearFindingState()
|
||||
loadVisits()
|
||||
}
|
||||
|
||||
fun startVisit() {
|
||||
val id = visit?.id ?: return
|
||||
launchBusy {
|
||||
visit = repository.startVisit(id)
|
||||
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||
notice = "Inspección iniciada."
|
||||
loadActsInternal(id, selectDraft = true)
|
||||
loadVisitsInternal()
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadActs() {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) }
|
||||
}
|
||||
|
||||
fun selectAct(actId: String) {
|
||||
launchBusy {
|
||||
selectedAct = actsRepository.get(actId)
|
||||
actClosure = actsRepository.closure(actId)
|
||||
clearFindingState()
|
||||
}
|
||||
}
|
||||
|
||||
fun createActForSelectedInventory(urgency: String = "NON_URGENT") {
|
||||
val currentVisit = visit ?: return
|
||||
val asset = selectedFieldAsset?.asset
|
||||
if (currentVisit.status != "IN_PROGRESS") {
|
||||
error = "La Inspección debe estar en curso para crear un Acta."
|
||||
return
|
||||
}
|
||||
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
||||
error = "Elegí si el Acta es urgente o no urgente."
|
||||
return
|
||||
}
|
||||
if (acts.any { it.status == "DRAFT" }) {
|
||||
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
val created = actsRepository.create(
|
||||
currentVisit.id,
|
||||
asset?.id,
|
||||
currentVisit.code,
|
||||
urgency,
|
||||
)
|
||||
selectedAct = created
|
||||
actClosure = actsRepository.closure(created.id)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
|
||||
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
|
||||
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
|
||||
|
||||
fun searchInventory(search: String, parentId: String? = null) {
|
||||
val currentVisit = visit ?: return
|
||||
launchBusy {
|
||||
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFieldTypes(parentId: String? = null) {
|
||||
val currentVisit = visit ?: return
|
||||
launchBusy {
|
||||
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
|
||||
inventoryParentId = effectiveParentId
|
||||
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
|
||||
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadCurrentInventory(visitId: String) {
|
||||
val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
|
||||
}
|
||||
|
||||
fun selectExisting(item: FieldInventoryItem) {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy {
|
||||
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
||||
notice = "Inventario agregado a la Inspección."
|
||||
reloadCurrentInventory(visitId)
|
||||
val draft = selectedDraftAct()
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
loadFindingOptionsInternal(visitId, item.id, draft.id)
|
||||
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createFieldAsset(
|
||||
type: FieldType,
|
||||
parentId: String?,
|
||||
familyId: String?,
|
||||
name: String,
|
||||
commonName: String?,
|
||||
attributes: Map<String, Any?>,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
) {
|
||||
val visitId = visit?.id ?: return
|
||||
if (visit?.status != "IN_PROGRESS") {
|
||||
error = "La Inspección debe estar en curso para dar de alta Inventario."
|
||||
return
|
||||
}
|
||||
if (type.familyRequired && familyId == null) {
|
||||
error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
val request = CreateFieldInventoryRequest(
|
||||
typeId = type.id,
|
||||
parentId = parentId,
|
||||
familyId = familyId,
|
||||
name = name.trim(),
|
||||
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
|
||||
attributes = attributes,
|
||||
deviceLatitude = latitude,
|
||||
deviceLongitude = longitude,
|
||||
deviceAccuracyM = accuracyM,
|
||||
deviceCapturedAt = Instant.now().toString(),
|
||||
)
|
||||
selectedFieldAsset = repository.createFieldAsset(visitId, request)
|
||||
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
|
||||
reloadCurrentInventory(visitId)
|
||||
}
|
||||
}
|
||||
|
||||
fun mergeCreatedFieldAsset(canonicalAssetId: String, reason: String) {
|
||||
val visitId = visit?.id ?: return
|
||||
val source = selectedFieldAsset?.asset ?: return
|
||||
if (canonicalAssetId.isBlank()) {
|
||||
error = "Elegí el registro existente que se conservará."
|
||||
return
|
||||
}
|
||||
if (reason.trim().length < 8) {
|
||||
error = "Explicá brevemente por qué se trata de un duplicado."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
val result = repository.mergeFieldAsset(
|
||||
visitId = visitId,
|
||||
assetId = source.id,
|
||||
canonicalAssetId = canonicalAssetId,
|
||||
reason = reason,
|
||||
)
|
||||
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
|
||||
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
|
||||
reloadCurrentInventory(visitId)
|
||||
val draft = selectedDraftAct()
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadFieldPhoto(
|
||||
file: File,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
) {
|
||||
val visitId = visit?.id ?: return
|
||||
val asset = selectedFieldAsset?.asset ?: return
|
||||
launchBusy {
|
||||
val response = repository.uploadFieldPhoto(
|
||||
visitId = visitId,
|
||||
assetId = asset.id,
|
||||
file = file,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
accuracyM = accuracyM,
|
||||
)
|
||||
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
|
||||
notice = if (response.capture.readyForFinding) {
|
||||
"Captura completa: GPS y fotografía registrados."
|
||||
} else {
|
||||
"Fotografía registrada."
|
||||
}
|
||||
reloadCurrentInventory(visitId)
|
||||
val draft = selectedDraftAct()
|
||||
if (response.capture.readyForFinding && draft != null) {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
loadFindingOptionsInternal(visitId, asset.id, draft.id)
|
||||
} else if (response.capture.readyForFinding) {
|
||||
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openFindingForSelected() {
|
||||
val visitId = visit?.id ?: return
|
||||
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||
val draft = selectedDraftAct()
|
||||
if (draft == null) {
|
||||
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
loadFindingOptionsInternal(visitId, assetId, draft.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun createFieldFinding(
|
||||
catalogItemId: String?,
|
||||
customTitle: String?,
|
||||
customLegalBasis: String?,
|
||||
description: String,
|
||||
severity: Int?,
|
||||
correctionDueOn: String?,
|
||||
) {
|
||||
val visitId = visit?.id ?: return
|
||||
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||
val actId = selectedDraftAct()?.id
|
||||
if (actId == null) {
|
||||
error = "No hay un Acta en borrador seleccionada."
|
||||
return
|
||||
}
|
||||
if (description.isBlank()) {
|
||||
error = "Describí el Hallazgo antes de guardarlo."
|
||||
return
|
||||
}
|
||||
if (catalogItemId == null && customTitle.isNullOrBlank()) {
|
||||
error = "Para OTROS, indicá un título para el Hallazgo."
|
||||
return
|
||||
}
|
||||
if (severity != null && severity !in 1..10) {
|
||||
error = "La gravedad debe estar entre 1 y 10."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
selectedAct = actsRepository.ensureAsset(actId, assetId)
|
||||
val response = findingsRepository.create(
|
||||
visitId,
|
||||
assetId,
|
||||
CreateFieldFindingRequest(
|
||||
actId = actId,
|
||||
catalogItemId = catalogItemId,
|
||||
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
|
||||
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
|
||||
description = description.trim(),
|
||||
severity = severity,
|
||||
correctionDueOn = correctionDueOn?.trim()?.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
lastCreatedFinding = response.finding
|
||||
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica."
|
||||
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
|
||||
loadActsInternal(visitId, selectDraft = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadFindingPhoto(
|
||||
findingId: String,
|
||||
file: File,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
) {
|
||||
launchBusy {
|
||||
findingsRepository.uploadObservationPhoto(
|
||||
findingId = findingId,
|
||||
file = file,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
accuracyM = accuracyM,
|
||||
title = title,
|
||||
description = description,
|
||||
)
|
||||
loadEvidenceInternal(findingId)
|
||||
notice = "Evidencia fotográfica registrada con GPS."
|
||||
}
|
||||
}
|
||||
|
||||
fun reloadFindingEvidence(findingId: String) {
|
||||
launchBusy { loadEvidenceInternal(findingId) }
|
||||
}
|
||||
|
||||
fun setCompanyResponsiblePresent(
|
||||
fullName: String,
|
||||
documentType: String,
|
||||
documentNumber: String,
|
||||
position: String,
|
||||
email: String?,
|
||||
phone: String?,
|
||||
) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) {
|
||||
error = "Completá nombre, documento y cargo del responsable de la empresa."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
actClosure = actsRepository.setResponsible(
|
||||
actId,
|
||||
MobileResponsibleRequest(
|
||||
attendanceStatus = "PRESENT",
|
||||
fullName = fullName.trim(),
|
||||
documentType = documentType,
|
||||
documentNumber = documentNumber.trim(),
|
||||
position = position.trim(),
|
||||
email = email?.trim()?.takeIf { it.isNotBlank() },
|
||||
phone = phone?.trim()?.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
notice = "Responsable de empresa registrado para el Acta."
|
||||
}
|
||||
}
|
||||
|
||||
fun setCompanyResponsibleAbsent(reason: String) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
if (reason.trim().length < 10) {
|
||||
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
actClosure = actsRepository.setResponsible(
|
||||
actId,
|
||||
MobileResponsibleRequest(
|
||||
attendanceStatus = "ABSENT",
|
||||
absenceReason = reason.trim(),
|
||||
),
|
||||
)
|
||||
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta."
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareSelectedAct() {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.lock(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
|
||||
}
|
||||
}
|
||||
|
||||
fun reopenSelectedAct() {
|
||||
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
|
||||
}
|
||||
|
||||
fun signSelectedActAsInspector(
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
|
||||
notice = "Firma del inspector incorporada al Acta."
|
||||
}
|
||||
}
|
||||
|
||||
fun signSelectedActAsCompany(
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
manifestation: String,
|
||||
statement: String?,
|
||||
) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.signCompany(
|
||||
actId, png, latitude, longitude, accuracyM, manifestation, statement,
|
||||
)
|
||||
notice = if (manifestation == "DISSENT") {
|
||||
"Firma de empresa registrada con disidencia."
|
||||
} else {
|
||||
"Firma de empresa registrada."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun recordCompanyOutcome(status: String, reason: String) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
if (status != "REFUSED") {
|
||||
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
|
||||
return
|
||||
}
|
||||
if (reason.trim().length < 10) {
|
||||
error = "Indicá un motivo de al menos 10 caracteres."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
actClosure = actsRepository.companyOutcome(actId, status, reason)
|
||||
notice = "Negativa a firmar asentada."
|
||||
}
|
||||
}
|
||||
|
||||
fun closeSelectedAct() {
|
||||
val currentVisit = visit ?: return
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.seal(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
clearFindingState()
|
||||
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
|
||||
}
|
||||
}
|
||||
|
||||
fun closeInspection() {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy {
|
||||
visit = actsRepository.closeVisit(visitId)
|
||||
loadVisitsInternal()
|
||||
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFindingFlow() {
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
fieldFindingEvidence = emptyMap()
|
||||
error = null
|
||||
}
|
||||
|
||||
fun clearSelectedFieldAsset() {
|
||||
selectedFieldAsset = null
|
||||
clearFindingState()
|
||||
}
|
||||
|
||||
private fun selectedDraftAct(): MobileActDetail? =
|
||||
selectedAct?.takeIf { it.status == "DRAFT" }
|
||||
?: acts.firstOrNull { it.status == "DRAFT" }?.let { summary ->
|
||||
selectedAct?.takeIf { it.id == summary.id && it.status == "DRAFT" }
|
||||
}
|
||||
|
||||
private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean) {
|
||||
acts = actsRepository.list(visitId).data
|
||||
val currentId = selectedAct?.id
|
||||
val current = currentId?.let { id -> acts.firstOrNull { it.id == id } }
|
||||
val target = when {
|
||||
current != null -> current.id
|
||||
selectDraft -> acts.firstOrNull { it.status == "DRAFT" }?.id
|
||||
else -> null
|
||||
}
|
||||
if (target != null) {
|
||||
selectedAct = actsRepository.get(target)
|
||||
actClosure = actsRepository.closure(target)
|
||||
} else if (current == null) {
|
||||
selectedAct = null
|
||||
actClosure = null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshSelectedActInternal(actId: String) {
|
||||
selectedAct = actsRepository.get(actId)
|
||||
actClosure = actsRepository.closure(actId)
|
||||
visit?.id?.let { loadActsInternal(it, selectDraft = false) }
|
||||
}
|
||||
|
||||
private suspend fun loadFindingOptionsInternal(
|
||||
visitId: String,
|
||||
assetId: String,
|
||||
actId: String,
|
||||
keepLastCreated: Boolean = false,
|
||||
) {
|
||||
val options = findingsRepository.options(visitId, assetId, actId)
|
||||
fieldFindingOptions = options
|
||||
if (!keepLastCreated) lastCreatedFinding = null
|
||||
val loaded = linkedMapOf<String, List<FieldFindingEvidence>>()
|
||||
for (finding in options.findings) {
|
||||
loaded[finding.id] = findingsRepository.evidence(finding.id).data
|
||||
}
|
||||
fieldFindingEvidence = loaded
|
||||
}
|
||||
|
||||
private suspend fun loadEvidenceInternal(findingId: String) {
|
||||
fieldFindingEvidence = fieldFindingEvidence + (
|
||||
findingId to findingsRepository.evidence(findingId).data
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearActState() {
|
||||
acts = emptyList()
|
||||
selectedAct = null
|
||||
actClosure = null
|
||||
}
|
||||
|
||||
private fun clearFindingState() {
|
||||
fieldFindingOptions = null
|
||||
lastCreatedFinding = null
|
||||
fieldFindingEvidence = emptyMap()
|
||||
}
|
||||
|
||||
private fun launchBusy(block: suspend () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
busy = true
|
||||
error = null
|
||||
try {
|
||||
block()
|
||||
} catch (throwable: Throwable) {
|
||||
error = DhRepository.humanError(throwable)
|
||||
if (repository.currentSession() == null) session = null
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,579 +0,0 @@
|
||||
package com.korexlabs.dhinspeccion.data
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
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.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
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.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
// ---------- Auth ----------
|
||||
|
||||
data class LoginRequest(
|
||||
val identifier: String,
|
||||
val password: String,
|
||||
val deviceLabel: String = "DH Android",
|
||||
)
|
||||
|
||||
data class RefreshRequest(val refreshToken: String)
|
||||
|
||||
data class MobileUser(
|
||||
val id: String,
|
||||
val username: String,
|
||||
val firstName: String? = null,
|
||||
val lastName: String? = null,
|
||||
val email: String? = null,
|
||||
val mustChangePassword: Boolean = false,
|
||||
val roles: List<String> = emptyList(),
|
||||
val permissions: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
data class MobileSessionResponse(
|
||||
val user: MobileUser,
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val accessExpiresInSeconds: Long,
|
||||
)
|
||||
|
||||
data class StoredSession(
|
||||
val userId: String,
|
||||
val username: String,
|
||||
val displayName: String,
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
)
|
||||
|
||||
// ---------- Inspections ----------
|
||||
|
||||
data class AssetSummary(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val name: String,
|
||||
val typeName: String? = null,
|
||||
)
|
||||
|
||||
data class PersonSummary(
|
||||
val id: String,
|
||||
val username: String? = null,
|
||||
val firstName: String? = null,
|
||||
val lastName: String? = null,
|
||||
)
|
||||
|
||||
data class VisitSummary(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val objective: String? = null,
|
||||
val status: String,
|
||||
val scopeAsset: AssetSummary? = null,
|
||||
val operationalArea: AssetSummary? = null,
|
||||
val operatorCompany: AssetSummary? = null,
|
||||
val leadInspector: PersonSummary? = null,
|
||||
val plannedStartAt: String? = null,
|
||||
val actualStartedAt: String? = null,
|
||||
val actualClosedAt: String? = null,
|
||||
val instructions: String? = null,
|
||||
val checklistGeneration: Int = 0,
|
||||
val assetCount: Int = 0,
|
||||
val memberCount: Int = 0,
|
||||
)
|
||||
|
||||
data class VisitMeta(
|
||||
val page: Int,
|
||||
val pageSize: Int,
|
||||
val total: Int,
|
||||
val totalPages: Int,
|
||||
)
|
||||
|
||||
data class VisitListResponse(val data: List<VisitSummary>, val meta: VisitMeta)
|
||||
|
||||
data class PlannedAsset(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val name: String,
|
||||
val typeName: String? = null,
|
||||
val included: Boolean = true,
|
||||
val planningSource: String? = null,
|
||||
val exclusionReason: String? = null,
|
||||
)
|
||||
|
||||
data class ChecklistItem(
|
||||
val id: String,
|
||||
val findingId: String? = null,
|
||||
val findingCode: String? = null,
|
||||
val findingTitle: String? = null,
|
||||
val findingStatus: String? = null,
|
||||
val severity: Int? = null,
|
||||
val itemKind: String? = null,
|
||||
val referenceOn: String? = null,
|
||||
val asset: AssetSummary? = null,
|
||||
val assetIncluded: Boolean = true,
|
||||
)
|
||||
|
||||
data class ChecklistSummary(
|
||||
val generation: Int = 0,
|
||||
val stale: Boolean = false,
|
||||
val antecedents: Int = 0,
|
||||
val companyOverdue: Int = 0,
|
||||
val verificationOverdue: Int = 0,
|
||||
val upcomingControls: Int = 0,
|
||||
val actionableAssets: Int = 0,
|
||||
val excludedAssets: Int = 0,
|
||||
val items: List<ChecklistItem> = emptyList(),
|
||||
)
|
||||
|
||||
data class VisitDetail(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val objective: String? = null,
|
||||
val status: String,
|
||||
val scopeAsset: AssetSummary? = null,
|
||||
val operationalArea: AssetSummary? = null,
|
||||
val operatorCompany: AssetSummary? = null,
|
||||
val leadInspector: PersonSummary? = null,
|
||||
val plannedStartAt: String? = null,
|
||||
val actualStartedAt: String? = null,
|
||||
val actualClosedAt: String? = null,
|
||||
val instructions: String? = null,
|
||||
val assets: List<AssetSummary> = emptyList(),
|
||||
val planningAssets: List<PlannedAsset> = emptyList(),
|
||||
val team: List<PersonSummary> = emptyList(),
|
||||
val checklist: ChecklistSummary = ChecklistSummary(),
|
||||
)
|
||||
|
||||
// ---------- Field inventory ----------
|
||||
|
||||
data class FieldContext(
|
||||
val visitId: String? = null,
|
||||
val visitCode: String? = null,
|
||||
val areaId: String? = null,
|
||||
val areaCode: String? = null,
|
||||
val areaName: String? = null,
|
||||
val companyId: String? = null,
|
||||
val companyCode: String? = null,
|
||||
val companyName: String? = null,
|
||||
)
|
||||
|
||||
data class FieldInventoryItem(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val name: String,
|
||||
val commonName: String? = null,
|
||||
val informationStatus: String? = null,
|
||||
val dataOrigin: String? = null,
|
||||
val type: AssetSummary? = null,
|
||||
val parent: AssetSummary? = null,
|
||||
val selectedInInspection: Boolean = false,
|
||||
val captureRequired: Boolean = false,
|
||||
val hasGeometry: Boolean = false,
|
||||
val fieldPhotoCount: Int = 0,
|
||||
val readyForFinding: Boolean = true,
|
||||
)
|
||||
|
||||
data class FieldInventoryListResponse(
|
||||
val context: FieldContext,
|
||||
val data: List<FieldInventoryItem>,
|
||||
)
|
||||
|
||||
data class FieldAttributeDefinition(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val name: String,
|
||||
val dataType: String,
|
||||
val isRequired: Boolean = false,
|
||||
val unit: String? = null,
|
||||
val options: Any? = null,
|
||||
val sortOrder: Int = 0,
|
||||
)
|
||||
|
||||
data class FieldInventoryFamily(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val name: String,
|
||||
val level: String,
|
||||
val informationLabels: List<String> = emptyList(),
|
||||
val sourceReference: String? = null,
|
||||
val isOther: Boolean = false,
|
||||
)
|
||||
|
||||
data class FieldType(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
val structuralKind: String? = null,
|
||||
val families: List<FieldInventoryFamily> = emptyList(),
|
||||
val familyRequired: Boolean = false,
|
||||
val attributes: List<FieldAttributeDefinition> = emptyList(),
|
||||
)
|
||||
|
||||
data class FieldTypeResponse(
|
||||
val context: FieldContext,
|
||||
val parent: AssetSummary,
|
||||
val data: List<FieldType>,
|
||||
)
|
||||
|
||||
data class CaptureStatus(
|
||||
val captureRequired: Boolean = false,
|
||||
val hasGeometry: Boolean = false,
|
||||
val creationGpsCaptured: Boolean = false,
|
||||
val fieldPhotoCount: Int = 0,
|
||||
val readyForFinding: Boolean = true,
|
||||
)
|
||||
|
||||
data class FieldAssetDetail(
|
||||
val context: FieldContext? = null,
|
||||
val asset: FieldInventoryItem,
|
||||
val selectedInInspection: Boolean = true,
|
||||
val capture: CaptureStatus = CaptureStatus(),
|
||||
)
|
||||
|
||||
data class CreateFieldInventoryRequest(
|
||||
val typeId: String,
|
||||
val parentId: String? = null,
|
||||
val familyId: String? = null,
|
||||
val code: String? = null,
|
||||
val name: String,
|
||||
val commonName: String? = null,
|
||||
val description: String? = null,
|
||||
val discoveryNotes: String? = null,
|
||||
val attributes: Map<String, Any?>,
|
||||
val deviceLatitude: Double,
|
||||
val deviceLongitude: Double,
|
||||
val deviceAccuracyM: Double? = null,
|
||||
val deviceCapturedAt: String,
|
||||
val deviceLabel: String = "DH Android",
|
||||
)
|
||||
|
||||
data class FieldPhotoResponse(
|
||||
val capture: CaptureStatus,
|
||||
)
|
||||
|
||||
data class MergeFieldInventoryRequest(
|
||||
val canonicalAssetId: String,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
data class FieldInventoryMergeResult(
|
||||
val source: AssetSummary,
|
||||
val canonical: AssetSummary,
|
||||
val sourceVersionNumber: Int,
|
||||
val reparentedChildIds: List<String> = emptyList(),
|
||||
val historyPolicy: String,
|
||||
)
|
||||
|
||||
interface DhApi {
|
||||
@POST("auth/mobile/login")
|
||||
suspend fun login(@Body request: LoginRequest): MobileSessionResponse
|
||||
|
||||
@POST("auth/mobile/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
|
||||
|
||||
@POST("auth/mobile/logout")
|
||||
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Any?>
|
||||
|
||||
@GET("inspection-visits")
|
||||
suspend fun visits(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Query("inspectorId") inspectorId: String,
|
||||
@Query("pageSize") pageSize: Int = 100,
|
||||
): VisitListResponse
|
||||
|
||||
@GET("inspection-visits/{id}")
|
||||
suspend fun visit(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("id") id: String,
|
||||
): VisitDetail
|
||||
|
||||
@POST("inspection-visits/{id}/start")
|
||||
suspend fun startVisit(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("id") id: String,
|
||||
): VisitDetail
|
||||
|
||||
@GET("inspection-visits/{visitId}/field-inventory")
|
||||
suspend fun fieldInventory(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Query("search") search: String? = null,
|
||||
@Query("parentId") parentId: String? = null,
|
||||
@Query("limit") limit: Int = 80,
|
||||
): FieldInventoryListResponse
|
||||
|
||||
@GET("inspection-visits/{visitId}/field-inventory/types")
|
||||
suspend fun fieldTypes(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Query("parentId") parentId: String? = null,
|
||||
): FieldTypeResponse
|
||||
|
||||
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/select")
|
||||
suspend fun selectFieldAsset(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Path("assetId") assetId: String,
|
||||
): FieldAssetDetail
|
||||
|
||||
@POST("inspection-visits/{visitId}/field-inventory")
|
||||
suspend fun createFieldAsset(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Body request: CreateFieldInventoryRequest,
|
||||
): FieldAssetDetail
|
||||
|
||||
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/merge")
|
||||
suspend fun mergeFieldAsset(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Path("assetId") assetId: String,
|
||||
@Body request: MergeFieldInventoryRequest,
|
||||
): FieldInventoryMergeResult
|
||||
|
||||
@Multipart
|
||||
@POST("inspection-visits/{visitId}/field-inventory/{assetId}/photos")
|
||||
suspend fun uploadFieldPhoto(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Path("assetId") assetId: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("deviceLatitude") latitude: okhttp3.RequestBody,
|
||||
@Part("deviceLongitude") longitude: okhttp3.RequestBody,
|
||||
@Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?,
|
||||
@Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody,
|
||||
@Part("deviceLabel") deviceLabel: okhttp3.RequestBody,
|
||||
@Part("exifLatitude") exifLatitude: okhttp3.RequestBody?,
|
||||
@Part("exifLongitude") exifLongitude: okhttp3.RequestBody?,
|
||||
@Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?,
|
||||
): FieldPhotoResponse
|
||||
}
|
||||
|
||||
class SecureSessionStore(context: Context) {
|
||||
private val prefs = context.getSharedPreferences("dh_v2_mobile_session", Context.MODE_PRIVATE)
|
||||
private val alias = "dh_v2_mobile_session_key"
|
||||
|
||||
fun load(): StoredSession? {
|
||||
val encoded = prefs.getString("payload", null) ?: return null
|
||||
return runCatching {
|
||||
val parts = encoded.split('.', limit = 2)
|
||||
require(parts.size == 2)
|
||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
||||
val encrypted = Base64.decode(parts[1], Base64.NO_WRAP)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, iv))
|
||||
val json = JSONObject(String(cipher.doFinal(encrypted), Charsets.UTF_8))
|
||||
StoredSession(
|
||||
userId = json.getString("userId"),
|
||||
username = json.getString("username"),
|
||||
displayName = json.optString("displayName", json.getString("username")),
|
||||
accessToken = json.getString("accessToken"),
|
||||
refreshToken = json.getString("refreshToken"),
|
||||
)
|
||||
}.getOrElse {
|
||||
clear()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun save(response: MobileSessionResponse): StoredSession {
|
||||
val displayName = listOfNotNull(response.user.firstName, response.user.lastName)
|
||||
.joinToString(" ").trim().ifBlank { response.user.username }
|
||||
val stored = StoredSession(
|
||||
userId = response.user.id,
|
||||
username = response.user.username,
|
||||
displayName = displayName,
|
||||
accessToken = response.accessToken,
|
||||
refreshToken = response.refreshToken,
|
||||
)
|
||||
val json = JSONObject()
|
||||
.put("userId", stored.userId)
|
||||
.put("username", stored.username)
|
||||
.put("displayName", stored.displayName)
|
||||
.put("accessToken", stored.accessToken)
|
||||
.put("refreshToken", stored.refreshToken)
|
||||
.toString()
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key())
|
||||
val encrypted = cipher.doFinal(json.toByteArray(Charsets.UTF_8))
|
||||
val payload = Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + "." +
|
||||
Base64.encodeToString(encrypted, Base64.NO_WRAP)
|
||||
prefs.edit().putString("payload", payload).apply()
|
||||
return stored
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(keyStore.getKey(alias, null) as? SecretKey)?.let { return it }
|
||||
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
alias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build(),
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
}
|
||||
|
||||
class DhRepository(context: Context) {
|
||||
private val store = SecureSessionStore(context.applicationContext)
|
||||
private val refreshMutex = Mutex()
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val api: DhApi = Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(OkHttpClient.Builder().build())
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(DhApi::class.java)
|
||||
|
||||
fun currentSession(): StoredSession? = store.load()
|
||||
|
||||
suspend fun login(identifier: String, password: String): StoredSession =
|
||||
store.save(api.login(LoginRequest(identifier.trim(), password)))
|
||||
|
||||
suspend fun logout() {
|
||||
val session = store.load()
|
||||
if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") }
|
||||
store.clear()
|
||||
}
|
||||
|
||||
suspend fun visits(): VisitListResponse = authorized { session ->
|
||||
api.visits("Bearer ${session.accessToken}", session.userId)
|
||||
}
|
||||
|
||||
suspend fun visit(id: String): VisitDetail = authorized { session ->
|
||||
api.visit("Bearer ${session.accessToken}", id)
|
||||
}
|
||||
|
||||
suspend fun startVisit(id: String): VisitDetail = authorized { session ->
|
||||
api.startVisit("Bearer ${session.accessToken}", id)
|
||||
}
|
||||
|
||||
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session ->
|
||||
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
|
||||
}
|
||||
|
||||
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session ->
|
||||
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
|
||||
}
|
||||
|
||||
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
|
||||
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
|
||||
}
|
||||
|
||||
suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = authorized { session ->
|
||||
api.createFieldAsset("Bearer ${session.accessToken}", visitId, request)
|
||||
}
|
||||
|
||||
suspend fun mergeFieldAsset(
|
||||
visitId: String,
|
||||
assetId: String,
|
||||
canonicalAssetId: String,
|
||||
reason: String,
|
||||
): FieldInventoryMergeResult = authorized { session ->
|
||||
api.mergeFieldAsset(
|
||||
"Bearer ${session.accessToken}",
|
||||
visitId,
|
||||
assetId,
|
||||
MergeFieldInventoryRequest(canonicalAssetId, reason.trim()),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun uploadFieldPhoto(
|
||||
visitId: String,
|
||||
assetId: String,
|
||||
file: File,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
capturedAt: String = Instant.now().toString(),
|
||||
): FieldPhotoResponse = authorized { session ->
|
||||
val text = "text/plain".toMediaType()
|
||||
val body = file.asRequestBody("image/jpeg".toMediaType())
|
||||
val part = MultipartBody.Part.createFormData("file", file.name, body)
|
||||
api.uploadFieldPhoto(
|
||||
authorization = "Bearer ${session.accessToken}",
|
||||
visitId = visitId,
|
||||
assetId = assetId,
|
||||
file = part,
|
||||
latitude = latitude.toString().toRequestBody(text),
|
||||
longitude = longitude.toString().toRequestBody(text),
|
||||
accuracy = accuracyM?.toString()?.toRequestBody(text),
|
||||
capturedAt = capturedAt.toRequestBody(text),
|
||||
deviceLabel = "DH Android".toRequestBody(text),
|
||||
exifLatitude = latitude.toString().toRequestBody(text),
|
||||
exifLongitude = longitude.toString().toRequestBody(text),
|
||||
exifCapturedAt = capturedAt.toRequestBody(text),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun humanError(error: Throwable): String {
|
||||
if (error is HttpException) {
|
||||
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
|
||||
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
|
||||
if (!message.isNullOrBlank()) return message
|
||||
return "Error HTTP ${error.code()}"
|
||||
}
|
||||
return error.message ?: "Ocurrió un error inesperado"
|
||||
}
|
||||
|
||||
fun newOperationId(): String = UUID.randomUUID().toString()
|
||||
}
|
||||
}
|
||||
@@ -1,258 +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.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
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.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
data class FieldFindingAct(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val status: String,
|
||||
)
|
||||
|
||||
data class FieldFindingCatalogItem(
|
||||
val id: String,
|
||||
val categoryId: String,
|
||||
val code: String,
|
||||
val sourceNumber: Int,
|
||||
val title: String,
|
||||
val legalBasis: String? = null,
|
||||
val glossary: String? = null,
|
||||
val suggestedSeverity: Int? = null,
|
||||
val revision: Int = 1,
|
||||
val categoryName: String? = null,
|
||||
)
|
||||
|
||||
data class FieldFindingOther(
|
||||
val enabled: Boolean = true,
|
||||
val code: String = "OTHER",
|
||||
val label: String = "OTROS",
|
||||
val help: String? = null,
|
||||
)
|
||||
|
||||
data class FieldFindingCatalog(
|
||||
val typeConfigured: Boolean = false,
|
||||
val configurationReason: String? = null,
|
||||
val items: List<FieldFindingCatalogItem> = emptyList(),
|
||||
val other: FieldFindingOther = FieldFindingOther(),
|
||||
)
|
||||
|
||||
data class FieldFindingItem(
|
||||
val id: String,
|
||||
val actId: String,
|
||||
val assetId: String,
|
||||
val catalogItemId: String? = null,
|
||||
val findingNumber: Int,
|
||||
val code: String,
|
||||
val status: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val severity: Int? = null,
|
||||
val suggestedSeverity: Int? = null,
|
||||
val correctionDueOn: String? = null,
|
||||
)
|
||||
|
||||
data class FieldFindingEvidence(
|
||||
val id: String,
|
||||
val findingId: String,
|
||||
val kind: String,
|
||||
val purpose: String,
|
||||
val originalName: String,
|
||||
val mimeType: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256: String,
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val capturedAt: String? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val accuracyM: Double? = null,
|
||||
val deviceLabel: String? = null,
|
||||
val source: String,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
data class FieldFindingEvidenceListResponse(
|
||||
val data: List<FieldFindingEvidence> = emptyList(),
|
||||
)
|
||||
|
||||
data class FieldFindingOptionsResponse(
|
||||
val act: FieldFindingAct,
|
||||
val capture: CaptureStatus = CaptureStatus(),
|
||||
val assetIncludedInAct: Boolean = true,
|
||||
val catalog: FieldFindingCatalog,
|
||||
val findings: List<FieldFindingItem> = emptyList(),
|
||||
val canAddAnother: Boolean = true,
|
||||
val actSelectionMode: String = "EXPLICIT",
|
||||
)
|
||||
|
||||
data class CreateFieldFindingRequest(
|
||||
val actId: String,
|
||||
val catalogItemId: String? = null,
|
||||
val customTitle: String? = null,
|
||||
val customLegalBasis: String? = null,
|
||||
val description: String,
|
||||
val severity: Int? = null,
|
||||
val correctionDueOn: String? = null,
|
||||
)
|
||||
|
||||
data class FieldFindingCreateResponse(
|
||||
val act: FieldFindingAct,
|
||||
val capture: CaptureStatus = CaptureStatus(),
|
||||
val finding: FieldFindingItem,
|
||||
val canAddAnother: Boolean = true,
|
||||
val actSelectionMode: String = "EXPLICIT",
|
||||
)
|
||||
|
||||
private interface FieldFindingsApi {
|
||||
@GET("inspection-visits/{visitId}/field-findings/{assetId}/options")
|
||||
suspend fun options(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Path("assetId") assetId: String,
|
||||
@Query("actId") actId: String,
|
||||
): FieldFindingOptionsResponse
|
||||
|
||||
@POST("inspection-visits/{visitId}/field-findings/{assetId}")
|
||||
suspend fun create(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Path("assetId") assetId: String,
|
||||
@Body request: CreateFieldFindingRequest,
|
||||
): FieldFindingCreateResponse
|
||||
|
||||
@GET("inspection-findings/{findingId}/evidence")
|
||||
suspend fun evidence(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("findingId") findingId: String,
|
||||
): FieldFindingEvidenceListResponse
|
||||
|
||||
@Multipart
|
||||
@POST("inspection-findings/{findingId}/evidence")
|
||||
suspend fun uploadEvidence(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("findingId") findingId: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("kind") kind: RequestBody,
|
||||
@Part("purpose") purpose: RequestBody,
|
||||
@Part("title") title: RequestBody?,
|
||||
@Part("description") description: RequestBody?,
|
||||
@Part("capturedAt") capturedAt: RequestBody,
|
||||
@Part("latitude") latitude: RequestBody,
|
||||
@Part("longitude") longitude: RequestBody,
|
||||
@Part("accuracyM") accuracyM: RequestBody?,
|
||||
@Part("deviceLabel") deviceLabel: RequestBody,
|
||||
): FieldFindingEvidence
|
||||
|
||||
@POST("auth/mobile/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Cliente de campo para Hallazgos y sus evidencias append-only.
|
||||
* F3.2 exige que la APK identifique explícitamente el Acta activa.
|
||||
*/
|
||||
class FieldFindingsRepository(context: Context) {
|
||||
private val store = SecureSessionStore(context.applicationContext)
|
||||
private val refreshMutex = Mutex()
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val api: FieldFindingsApi = Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(OkHttpClient.Builder().build())
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(FieldFindingsApi::class.java)
|
||||
|
||||
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
|
||||
authorized { session ->
|
||||
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
|
||||
}
|
||||
|
||||
suspend fun create(
|
||||
visitId: String,
|
||||
assetId: String,
|
||||
request: CreateFieldFindingRequest,
|
||||
): FieldFindingCreateResponse = authorized { session ->
|
||||
api.create("Bearer ${session.accessToken}", visitId, assetId, request)
|
||||
}
|
||||
|
||||
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session ->
|
||||
api.evidence("Bearer ${session.accessToken}", findingId)
|
||||
}
|
||||
|
||||
suspend fun uploadObservationPhoto(
|
||||
findingId: String,
|
||||
file: File,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
capturedAt: String = Instant.now().toString(),
|
||||
): FieldFindingEvidence = authorized { session ->
|
||||
val text = "text/plain".toMediaType()
|
||||
val part = MultipartBody.Part.createFormData(
|
||||
"file",
|
||||
file.name,
|
||||
file.asRequestBody("image/jpeg".toMediaType()),
|
||||
)
|
||||
api.uploadEvidence(
|
||||
authorization = "Bearer ${session.accessToken}",
|
||||
findingId = findingId,
|
||||
file = part,
|
||||
kind = "PHOTO".toRequestBody(text),
|
||||
purpose = "OBSERVATION".toRequestBody(text),
|
||||
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
||||
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
||||
capturedAt = capturedAt.toRequestBody(text),
|
||||
latitude = latitude.toString().toRequestBody(text),
|
||||
longitude = longitude.toString().toRequestBody(text),
|
||||
accuracyM = accuracyM?.toString()?.toRequestBody(text),
|
||||
deviceLabel = "DH Android".toRequestBody(text),
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,471 +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.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
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.Multipart
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
|
||||
data class MobileActSummary(
|
||||
val id: String,
|
||||
val visitId: String,
|
||||
val code: String,
|
||||
val status: String,
|
||||
val occurredAt: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val urgency: String = "NON_URGENT",
|
||||
val deadlineDays: Int? = null,
|
||||
val deadlineDayType: String? = null,
|
||||
val deadlineBasis: String? = null,
|
||||
val deadlineBaseAt: String? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val lockedAt: String? = null,
|
||||
val lockedSha256: String? = null,
|
||||
val sealedAt: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
val assetCount: Int = 0,
|
||||
val findingCount: Int = 0,
|
||||
)
|
||||
|
||||
data class MobileActDetail(
|
||||
val id: String,
|
||||
val visitId: String,
|
||||
val code: String,
|
||||
val status: String,
|
||||
val occurredAt: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val urgency: String = "NON_URGENT",
|
||||
val deadlineDays: Int? = null,
|
||||
val deadlineDayType: String? = null,
|
||||
val deadlineBasis: String? = null,
|
||||
val deadlineBaseAt: String? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val lockedAt: String? = null,
|
||||
val lockedSha256: String? = null,
|
||||
val sealedAt: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
val assetCount: Int = 0,
|
||||
val findingCount: Int = 0,
|
||||
val assets: List<AssetSummary> = emptyList(),
|
||||
)
|
||||
|
||||
data class MobileActListMeta(
|
||||
val page: Int = 1,
|
||||
val pageSize: Int = 100,
|
||||
val total: Int = 0,
|
||||
val totalPages: Int = 0,
|
||||
)
|
||||
|
||||
data class MobileActListResponse(
|
||||
val data: List<MobileActSummary> = emptyList(),
|
||||
val meta: MobileActListMeta = MobileActListMeta(),
|
||||
)
|
||||
|
||||
data class CreateMobileActRequest(
|
||||
val occurredAt: String,
|
||||
val urgency: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val assetIds: List<String>,
|
||||
)
|
||||
|
||||
data class UpdateMobileActRequest(
|
||||
val assetIds: List<String>,
|
||||
)
|
||||
|
||||
data class MobileResponsibleRequest(
|
||||
val attendanceStatus: String,
|
||||
val fullName: String? = null,
|
||||
val documentType: String? = null,
|
||||
val documentNumber: String? = null,
|
||||
val position: String? = null,
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val absenceReason: String? = null,
|
||||
)
|
||||
|
||||
data class MobileResponsible(
|
||||
val actId: String,
|
||||
val attendanceStatus: String,
|
||||
val fullName: String? = null,
|
||||
val documentType: String? = null,
|
||||
val documentNumber: String? = null,
|
||||
val position: String? = null,
|
||||
val email: String? = null,
|
||||
val phone: String? = null,
|
||||
val absenceReason: String? = null,
|
||||
)
|
||||
|
||||
data class MobileActClosureHeader(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val status: String,
|
||||
val visitId: String,
|
||||
val urgency: String = "NON_URGENT",
|
||||
val deadlineDays: Int? = null,
|
||||
val deadlineDayType: String? = null,
|
||||
val deadlineBasis: String? = null,
|
||||
val deadlineBaseAt: String? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val lockedAt: String? = null,
|
||||
val lockedSha256: String? = null,
|
||||
val sealedAt: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
)
|
||||
|
||||
data class MobileVisitClosureHeader(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val status: String,
|
||||
val actualClosedAt: String? = null,
|
||||
)
|
||||
|
||||
data class MobileSignature(
|
||||
val id: String,
|
||||
val signerType: String,
|
||||
val signerUserId: String? = null,
|
||||
val signerName: String,
|
||||
val status: String,
|
||||
val reason: String? = null,
|
||||
val companyManifestation: String? = null,
|
||||
val companyStatement: String? = null,
|
||||
val signedAt: String? = null,
|
||||
val imageSha256: String? = null,
|
||||
)
|
||||
|
||||
data class MobileClosureRecord(
|
||||
val schemaVersion: String,
|
||||
val preparedSha256: String,
|
||||
val preparedAt: String,
|
||||
val finalSha256: String? = null,
|
||||
val deviceClosedAt: String? = null,
|
||||
val serverClosedAt: String? = null,
|
||||
val uploadMode: String? = null,
|
||||
val isCurrent: Boolean = true,
|
||||
)
|
||||
|
||||
data class MobileClosureConsents(
|
||||
val version: String = "",
|
||||
val inspector: String = "",
|
||||
val company: String = "",
|
||||
)
|
||||
|
||||
data class MobileActClosure(
|
||||
val act: MobileActClosureHeader,
|
||||
val visit: MobileVisitClosureHeader,
|
||||
val responsible: MobileResponsible? = null,
|
||||
val closure: MobileClosureRecord? = null,
|
||||
val signatures: List<MobileSignature> = emptyList(),
|
||||
val consents: MobileClosureConsents = MobileClosureConsents(),
|
||||
)
|
||||
|
||||
data class MobileCompanyOutcomeRequest(
|
||||
val status: String,
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
data class MobileSealActRequest(
|
||||
val clientClosedAt: String = Instant.now().toString(),
|
||||
val uploadMode: String = "ONLINE",
|
||||
)
|
||||
|
||||
data class MobileCloseVisitRequest(
|
||||
val clientClosedAt: String = Instant.now().toString(),
|
||||
)
|
||||
|
||||
private interface MobileActsApi {
|
||||
@GET("inspection-visits/{visitId}/acts")
|
||||
suspend fun listActs(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Query("pageSize") pageSize: Int = 100,
|
||||
): MobileActListResponse
|
||||
|
||||
@GET("inspection-acts/{actId}")
|
||||
suspend fun act(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActDetail
|
||||
|
||||
@POST("inspection-visits/{visitId}/acts")
|
||||
suspend fun createAct(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Body request: CreateMobileActRequest,
|
||||
): MobileActDetail
|
||||
|
||||
@PATCH("inspection-acts/{actId}")
|
||||
suspend fun updateAct(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: UpdateMobileActRequest,
|
||||
): MobileActDetail
|
||||
|
||||
@GET("inspection-acts/{actId}/closure")
|
||||
suspend fun closure(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
|
||||
@PUT("inspection-acts/{actId}/responsible")
|
||||
suspend fun responsible(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: MobileResponsibleRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/lock")
|
||||
suspend fun lock(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
|
||||
@Multipart
|
||||
@POST("inspection-acts/{actId}/signatures/inspector")
|
||||
suspend fun signInspector(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("consentAccepted") consentAccepted: RequestBody,
|
||||
@Part("clientSignedAt") clientSignedAt: RequestBody,
|
||||
@Part("latitude") latitude: RequestBody?,
|
||||
@Part("longitude") longitude: RequestBody?,
|
||||
@Part("accuracyM") accuracyM: RequestBody?,
|
||||
@Part("deviceLabel") deviceLabel: RequestBody,
|
||||
): MobileActClosure
|
||||
|
||||
@Multipart
|
||||
@POST("inspection-acts/{actId}/signatures/company")
|
||||
suspend fun signCompany(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("consentAccepted") consentAccepted: RequestBody,
|
||||
@Part("clientSignedAt") clientSignedAt: RequestBody,
|
||||
@Part("latitude") latitude: RequestBody?,
|
||||
@Part("longitude") longitude: RequestBody?,
|
||||
@Part("accuracyM") accuracyM: RequestBody?,
|
||||
@Part("deviceLabel") deviceLabel: RequestBody,
|
||||
@Part("manifestation") manifestation: RequestBody?,
|
||||
@Part("statement") statement: RequestBody?,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/company-outcome")
|
||||
suspend fun companyOutcome(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: MobileCompanyOutcomeRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/seal")
|
||||
suspend fun sealAct(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: MobileSealActRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-visits/{visitId}/close")
|
||||
suspend fun closeVisit(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@Body request: MobileCloseVisitRequest,
|
||||
): VisitDetail
|
||||
|
||||
@POST("auth/mobile/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
|
||||
}
|
||||
|
||||
class MobileActsRepository(context: Context) {
|
||||
private val store = SecureSessionStore(context.applicationContext)
|
||||
private val refreshMutex = Mutex()
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val api: MobileActsApi = Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(OkHttpClient.Builder().build())
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(MobileActsApi::class.java)
|
||||
|
||||
suspend fun list(visitId: String): MobileActListResponse = authorized { session ->
|
||||
api.listActs("Bearer ${session.accessToken}", visitId)
|
||||
}
|
||||
|
||||
suspend fun get(actId: String): MobileActDetail = authorized { session ->
|
||||
api.act("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun create(
|
||||
visitId: String,
|
||||
assetId: String? = null,
|
||||
visitCode: String,
|
||||
urgency: String = "NON_URGENT",
|
||||
): MobileActDetail = authorized { session ->
|
||||
api.createAct(
|
||||
"Bearer ${session.accessToken}",
|
||||
visitId,
|
||||
CreateMobileActRequest(
|
||||
occurredAt = Instant.now().toString(),
|
||||
urgency = urgency,
|
||||
title = "Acta de inspección $visitCode",
|
||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
|
||||
assetIds = listOfNotNull(assetId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
|
||||
val detail = get(actId)
|
||||
if (detail.assets.any { it.id == assetId }) return detail
|
||||
val ids = (detail.assets.map { it.id } + assetId).distinct()
|
||||
return authorized { session ->
|
||||
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun closure(actId: String): MobileActClosure = authorized { session ->
|
||||
api.closure("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session ->
|
||||
api.responsible("Bearer ${session.accessToken}", actId, request)
|
||||
}
|
||||
|
||||
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
|
||||
api.lock("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun signInspector(
|
||||
actId: String,
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
): MobileActClosure = signature(actId, png, latitude, longitude, accuracyM, company = false)
|
||||
|
||||
suspend fun signCompany(
|
||||
actId: String,
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
manifestation: String = "CONFORMITY",
|
||||
statement: String? = null,
|
||||
): MobileActClosure = signature(
|
||||
actId = actId,
|
||||
png = png,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
accuracyM = accuracyM,
|
||||
company = true,
|
||||
manifestation = manifestation,
|
||||
statement = statement,
|
||||
)
|
||||
|
||||
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session ->
|
||||
api.companyOutcome(
|
||||
"Bearer ${session.accessToken}",
|
||||
actId,
|
||||
MobileCompanyOutcomeRequest(status, reason.trim()),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
|
||||
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
|
||||
}
|
||||
|
||||
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
|
||||
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
|
||||
}
|
||||
|
||||
private suspend fun signature(
|
||||
actId: String,
|
||||
png: File,
|
||||
latitude: Double?,
|
||||
longitude: Double?,
|
||||
accuracyM: Double?,
|
||||
company: Boolean,
|
||||
manifestation: String? = null,
|
||||
statement: String? = null,
|
||||
): MobileActClosure = authorized { session ->
|
||||
val text = "text/plain".toMediaType()
|
||||
val file = MultipartBody.Part.createFormData(
|
||||
"file",
|
||||
png.name,
|
||||
png.asRequestBody("image/png".toMediaType()),
|
||||
)
|
||||
val consent = "true".toRequestBody(text)
|
||||
val signedAt = Instant.now().toString().toRequestBody(text)
|
||||
val device = "DH Android".toRequestBody(text)
|
||||
val lat = latitude?.toString()?.toRequestBody(text)
|
||||
val lon = longitude?.toString()?.toRequestBody(text)
|
||||
val accuracy = accuracyM?.toString()?.toRequestBody(text)
|
||||
if (company) {
|
||||
api.signCompany(
|
||||
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
|
||||
lat, lon, accuracy, device,
|
||||
manifestation?.toRequestBody(text),
|
||||
statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
|
||||
)
|
||||
} else {
|
||||
api.signInspector(
|
||||
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
|
||||
lat, lon, accuracy, device,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
-103
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,583 +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.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.text.input.PasswordVisualTransformation
|
||||
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 com.korexlabs.dhinspeccion.data.VisitSummary
|
||||
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 GeoSnapshot(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyM: Double?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DhApp(model: MainViewModel) {
|
||||
MaterialTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
model.session == null -> LoginScreen(model)
|
||||
model.visit == null -> VisitsScreen(model)
|
||||
else -> VisitRouter(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageStrip(model: MainViewModel) {
|
||||
val error = model.error
|
||||
val notice = model.notice
|
||||
if (error != null || notice != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
onClick = { model.clearMessages() },
|
||||
) {
|
||||
Text(
|
||||
text = error ?: notice.orEmpty(),
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoginScreen(model: MainViewModel) {
|
||||
var identifier by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Aplicación de campo · Dirección de Hidrocarburos")
|
||||
MessageStrip(model)
|
||||
OutlinedTextField(
|
||||
value = identifier,
|
||||
onValueChange = { identifier = it },
|
||||
label = { Text("Usuario o email") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Contraseña") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
)
|
||||
Button(
|
||||
onClick = { model.login(identifier, password) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !model.busy,
|
||||
) {
|
||||
if (model.busy) CircularProgressIndicator(modifier = Modifier.width(20.dp).height(20.dp))
|
||||
else Text("Ingresar")
|
||||
}
|
||||
Text(
|
||||
"El acceso móvil está reservado a usuarios con rol Inspector.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisitsScreen(model: MainViewModel) {
|
||||
val session = model.session ?: return
|
||||
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) { Text("Actualizar") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton(onClick = { model.logout() }) { Text("Salir") }
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 16.dp)) { MessageStrip(model) }
|
||||
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.")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(model.visits, key = { it.id }) { visit ->
|
||||
VisitCard(visit) { model.openVisit(visit.id) }
|
||||
}
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisitCard(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(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: ${shortDate(it)}", style = MaterialTheme.typography.bodySmall) }
|
||||
Text("Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisitRouter(model: MainViewModel) {
|
||||
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
|
||||
if (inventoryMode) {
|
||||
FieldInventoryScreen(model) { inventoryMode = false }
|
||||
} else {
|
||||
VisitScreen(model) { inventoryMode = true }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VisitScreen(model: MainViewModel, onInventory: () -> Unit) {
|
||||
val visit = model.visit ?: return
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 30.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedButton(onClick = { model.closeVisitView() }) { Text("Volver") }
|
||||
Text(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.instructions?.takeIf { it.isNotBlank() }?.let {
|
||||
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) { Text("Instrucciones", fontWeight = FontWeight.Bold); Text(it) } }
|
||||
}
|
||||
MessageStrip(model)
|
||||
|
||||
if (visit.status == "PLANNED") {
|
||||
Button(onClick = { model.startVisit() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Iniciar inspección")
|
||||
}
|
||||
}
|
||||
if (visit.status == "PLANNED" || visit.status == "IN_PROGRESS") {
|
||||
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { Text("Inventario de campo") }
|
||||
}
|
||||
|
||||
ChecklistCard(visit)
|
||||
|
||||
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (visit.planningAssets.isEmpty()) 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 ChecklistCard(visit: VisitDetail) {
|
||||
val checklist = visit.checklist
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Checklist de antecedentes", 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)
|
||||
checklist.items.take(10).forEach { item ->
|
||||
HorizontalDivider()
|
||||
Text(item.findingTitle ?: item.findingCode ?: "Hallazgo", fontWeight = FontWeight.SemiBold)
|
||||
Text("${item.asset?.name.orEmpty()} · Gravedad ${item.severity ?: "s/d"}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val visit = model.visit ?: return
|
||||
var search by rememberSaveable { mutableStateOf("") }
|
||||
var showCreate by rememberSaveable { mutableStateOf(false) }
|
||||
var parentId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var parentLabel by rememberSaveable { mutableStateOf("Área de la inspección") }
|
||||
var name by rememberSaveable { mutableStateOf("") }
|
||||
var commonName by rememberSaveable { mutableStateOf("") }
|
||||
var selectedTypeId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val attributeValues = remember { mutableStateMapOf<String, String>() }
|
||||
|
||||
LaunchedEffect(visit.id) {
|
||||
model.searchInventory("")
|
||||
model.loadFieldTypes(null)
|
||||
}
|
||||
LaunchedEffect(model.fieldTypes) {
|
||||
if (model.fieldTypes.none { it.id == selectedTypeId }) {
|
||||
selectedTypeId = model.fieldTypes.firstOrNull()?.id
|
||||
attributeValues.clear()
|
||||
}
|
||||
}
|
||||
|
||||
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
|
||||
|
||||
val createWithLocation: () -> Unit = {
|
||||
val type = selectedType
|
||||
if (type != null) {
|
||||
scope.launch {
|
||||
runCatching { currentGeo(context) }
|
||||
.onSuccess { geo ->
|
||||
val values = buildAttributes(type, attributeValues)
|
||||
model.createFieldAsset(type, parentId, name, commonName, values, geo.latitude, geo.longitude, geo.accuracyM)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val locationPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
val allowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||
if (allowed) createWithLocation()
|
||||
}
|
||||
|
||||
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
|
||||
var pendingPhotoGeo by remember { mutableStateOf<GeoSnapshot?>(null) }
|
||||
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
val file = pendingPhotoFile
|
||||
val geo = pendingPhotoGeo
|
||||
if (success && file != null && geo != null) {
|
||||
runCatching { writeExif(file, geo) }
|
||||
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
|
||||
}
|
||||
pendingPhotoFile = null
|
||||
pendingPhotoGeo = null
|
||||
}
|
||||
val beginPhoto: () -> Unit = {
|
||||
scope.launch {
|
||||
runCatching { currentGeo(context) }.onSuccess { geo ->
|
||||
val (file, uri) = newPhoto(context)
|
||||
pendingPhotoFile = file
|
||||
pendingPhotoGeo = geo
|
||||
takePicture.launch(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
val photoPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
val camera = result[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
|
||||
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true || result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || hasLocation(context)
|
||||
if (camera && location) beginPhoto()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
OutlinedButton(onClick = onBack) { Text("Volver") }
|
||||
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 16.dp)) { MessageStrip(model) }
|
||||
|
||||
val selectedCapture = model.selectedFieldAsset
|
||||
if (selectedCapture != null) {
|
||||
CaptureCard(
|
||||
detailName = selectedCapture.asset.name,
|
||||
captureRequired = selectedCapture.capture.captureRequired,
|
||||
gps = selectedCapture.capture.creationGpsCaptured,
|
||||
photos = selectedCapture.capture.fieldPhotoCount,
|
||||
ready = selectedCapture.capture.readyForFinding,
|
||||
onPhoto = {
|
||||
val permissions = arrayOf(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
if (hasPermission(context, Manifest.permission.CAMERA) && hasLocation(context)) beginPhoto()
|
||||
else photoPermissionLauncher.launch(permissions)
|
||||
},
|
||||
onClose = { model.clearSelectedFieldAsset() },
|
||||
)
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Buscar por nombre, código o atributo") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
|
||||
}
|
||||
if (visit.status == "IN_PROGRESS") {
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Alta en campo", fontWeight = FontWeight.Bold)
|
||||
OutlinedButton(onClick = {
|
||||
showCreate = !showCreate
|
||||
if (showCreate) model.loadFieldTypes(parentId)
|
||||
}) { Text(if (showCreate) "Ocultar" else "Crear nuevo") }
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreate && visit.status == "IN_PROGRESS") {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp).verticalScroll(rememberScrollState()).weight(1f, fill = false),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
|
||||
if (parentId != null) {
|
||||
OutlinedButton(onClick = {
|
||||
parentId = null
|
||||
parentLabel = "Área de la inspección"
|
||||
model.loadFieldTypes(null)
|
||||
}) { Text("Volver al Área") }
|
||||
}
|
||||
if (model.fieldTypes.isEmpty()) Text("No hay tipos habilitados debajo de este padre.")
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(model.fieldTypes, key = { it.id }) { type ->
|
||||
AssistChip(
|
||||
onClick = { selectedTypeId = type.id; attributeValues.clear() },
|
||||
label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(name, { name = it }, label = { Text("Nombre o código identificable *") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(commonName, { commonName = it }, label = { Text("Nombre común") }, 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 requiredReady = selectedType?.attributes?.filter { it.isRequired }?.all { attributeValues[it.code].orEmpty().isNotBlank() } ?: false
|
||||
Button(
|
||||
onClick = {
|
||||
if (hasLocation(context)) createWithLocation()
|
||||
else locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
|
||||
},
|
||||
enabled = selectedType != null && name.isNotBlank() && requiredReady && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Capturar GPS y crear") }
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
Text("Resultados", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(model.inventory, key = { it.id }) { item ->
|
||||
InventoryCard(
|
||||
item = item,
|
||||
canModify = visit.status == "IN_PROGRESS",
|
||||
onSelect = { model.selectExisting(item) },
|
||||
onUseParent = {
|
||||
parentId = item.id
|
||||
parentLabel = "${item.name} (${item.code})"
|
||||
showCreate = true
|
||||
selectedTypeId = null
|
||||
model.loadFieldTypes(item.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(30.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CaptureCard(
|
||||
detailName: String,
|
||||
captureRequired: Boolean,
|
||||
gps: Boolean,
|
||||
photos: Int,
|
||||
ready: Boolean,
|
||||
onPhoto: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(detailName, fontWeight = FontWeight.Bold)
|
||||
OutlinedButton(onClick = onClose) { Text("Cerrar") }
|
||||
}
|
||||
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
|
||||
if (captureRequired && !ready) {
|
||||
Text("El Hallazgo permanece bloqueado hasta completar GPS + foto.", color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
|
||||
} else if (ready) {
|
||||
Text("Captura completa · habilitado para Hallazgos", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect: () -> Unit, onUseParent: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(item.name, fontWeight = FontWeight.SemiBold)
|
||||
Text("${item.code} · ${item.type?.name.orEmpty()}", style = MaterialTheme.typography.bodySmall)
|
||||
item.commonName?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
Text(
|
||||
if (item.readyForFinding) "Listo" else "Captura incompleta: GPS/foto pendiente",
|
||||
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (canModify) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = onSelect) { Text(if (item.selectedInInspection) "Abrir" else "Seleccionar") }
|
||||
OutlinedButton(onClick = onUseParent) { Text("Crear hijo") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAttributes(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 coerceAttribute(definition, raw)
|
||||
}.toMap()
|
||||
|
||||
private fun coerceAttribute(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", "sí", "yes")
|
||||
else -> raw
|
||||
}
|
||||
|
||||
private fun hasPermission(context: Context, permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun hasLocation(context: Context): Boolean =
|
||||
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
|
||||
private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellableCoroutine { continuation ->
|
||||
if (!hasLocation(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(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() }
|
||||
}
|
||||
|
||||
private fun newPhoto(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_${System.currentTimeMillis()}_", ".jpg", directory)
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
return file to uri
|
||||
}
|
||||
|
||||
private fun writeExif(file: File, geo: GeoSnapshot) {
|
||||
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 shortDate(value: String): String = value.replace('T', ' ').take(16)
|
||||
@@ -1,786 +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.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.tasks.CancellationTokenSource
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||
import com.korexlabs.dhinspeccion.data.FieldType
|
||||
import com.korexlabs.dhinspeccion.data.VisitDetail
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private data class F3GeoSnapshot(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyM: Double?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun F3VisitRoot(model: MainViewModel) {
|
||||
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
|
||||
var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
|
||||
when {
|
||||
actsMode -> MobileActsScreen(
|
||||
model = model,
|
||||
onBack = { actsMode = false },
|
||||
onGoInventory = {
|
||||
actsMode = false
|
||||
inventoryMode = true
|
||||
},
|
||||
)
|
||||
inventoryMode -> F3FieldInventoryScreen(model, onBack = {
|
||||
inventoryMode = false
|
||||
actsMode = true
|
||||
})
|
||||
else -> F3VisitOverview(
|
||||
model = model,
|
||||
onInventory = { inventoryMode = true },
|
||||
onActs = { actsMode = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3VisitOverview(
|
||||
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(statusLabel(visit.status), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
|
||||
visit.scopeAsset?.let {
|
||||
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
visit.plannedStartAt?.let {
|
||||
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
visit.instructions?.takeIf { it.isNotBlank() }?.let {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text("Instrucciones", fontWeight = FontWeight.Bold)
|
||||
Text(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
F3MessageStrip(model)
|
||||
|
||||
if (visit.status == "PLANNED") {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
|
||||
Text("Al iniciar, pasás directamente a las Actas de esta inspección.")
|
||||
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") {
|
||||
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("Inventario / Hallazgos")
|
||||
}
|
||||
} else if (visit.status == "CLOSED") {
|
||||
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Ver Actas · ${model.acts.size}")
|
||||
}
|
||||
} else if (visit.status == "PLANNED") {
|
||||
Text(
|
||||
"Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
F3ChecklistCard(visit)
|
||||
|
||||
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (visit.planningAssets.none { it.included }) {
|
||||
Text("Sin elementos planificados.")
|
||||
}
|
||||
visit.planningAssets.filter { it.included }.forEach { asset ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(asset.name, fontWeight = FontWeight.SemiBold)
|
||||
Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3ChecklistCard(visit: VisitDetail) {
|
||||
val checklist = visit.checklist
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Antecedentes antes de salir", fontWeight = FontWeight.Bold)
|
||||
Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}")
|
||||
Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}")
|
||||
if (checklist.stale) {
|
||||
Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val visit = model.visit ?: return
|
||||
|
||||
var search by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
|
||||
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var parentLabel by rememberSaveable(visit.id) {
|
||||
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
|
||||
}
|
||||
var name by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
val attributeValues = remember { mutableStateMapOf<String, String>() }
|
||||
|
||||
var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(visit.id) {
|
||||
model.searchInventory("")
|
||||
model.loadFieldTypes(null)
|
||||
}
|
||||
LaunchedEffect(model.fieldTypes) {
|
||||
if (model.fieldTypes.none { it.id == selectedTypeId }) {
|
||||
selectedTypeId = model.fieldTypes.firstOrNull()?.id
|
||||
selectedFamilyId = null
|
||||
attributeValues.clear()
|
||||
}
|
||||
}
|
||||
|
||||
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
|
||||
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
|
||||
|
||||
fun resetCreateForm() {
|
||||
name = ""
|
||||
commonName = ""
|
||||
selectedFamilyId = null
|
||||
attributeValues.clear()
|
||||
}
|
||||
|
||||
val createWithLocation: () -> Unit = {
|
||||
val type = selectedType
|
||||
if (type != null) {
|
||||
scope.launch {
|
||||
runCatching { currentF3Geo(context) }
|
||||
.onSuccess { geo ->
|
||||
model.createFieldAsset(
|
||||
type = type,
|
||||
parentId = parentId,
|
||||
familyId = selectedFamilyId,
|
||||
name = name,
|
||||
commonName = commonName,
|
||||
attributes = buildF3Attributes(type, attributeValues),
|
||||
latitude = geo.latitude,
|
||||
longitude = geo.longitude,
|
||||
accuracyM = geo.accuracyM,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val locationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { result ->
|
||||
val allowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||
if (allowed) createWithLocation()
|
||||
}
|
||||
|
||||
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
|
||||
var pendingPhotoGeo by remember { mutableStateOf<F3GeoSnapshot?>(null) }
|
||||
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
val file = pendingPhotoFile
|
||||
val geo = pendingPhotoGeo
|
||||
if (success && file != null && geo != null) {
|
||||
runCatching { writeF3Exif(file, geo) }
|
||||
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
|
||||
}
|
||||
pendingPhotoFile = null
|
||||
pendingPhotoGeo = null
|
||||
}
|
||||
val beginPhoto: () -> Unit = {
|
||||
scope.launch {
|
||||
runCatching { currentF3Geo(context) }.onSuccess { geo ->
|
||||
val (file, uri) = newF3Photo(context)
|
||||
pendingPhotoFile = file
|
||||
pendingPhotoGeo = geo
|
||||
takePicture.launch(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
val photoPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { result ->
|
||||
val camera = result[Manifest.permission.CAMERA] == true || f3HasPermission(context, Manifest.permission.CAMERA)
|
||||
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || f3HasLocation(context)
|
||||
if (camera && location) beginPhoto()
|
||||
}
|
||||
|
||||
val selectedCapture = model.selectedFieldAsset
|
||||
val mergeSource = selectedCapture?.takeIf {
|
||||
it.capture.captureRequired &&
|
||||
it.asset.type?.let { type -> f3TypeCode(type) in setOf("instalacion", "subinstalacion") } == true
|
||||
}
|
||||
val mergeCandidates = remember(model.inventory, mergeSource) {
|
||||
if (mergeSource == null) emptyList() else model.inventory.filter { candidate ->
|
||||
candidate.id != mergeSource.asset.id &&
|
||||
candidate.informationStatus != "INACTIVE" &&
|
||||
candidate.type?.id == mergeSource.asset.type?.id &&
|
||||
candidate.parent?.id == mergeSource.asset.parent?.id &&
|
||||
!candidate.captureRequired
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onBack) { Text("Acta") }
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
|
||||
|
||||
if (selectedCapture != null) {
|
||||
F3CaptureCard(
|
||||
detailName = selectedCapture.asset.name,
|
||||
captureRequired = selectedCapture.capture.captureRequired,
|
||||
gps = selectedCapture.capture.creationGpsCaptured,
|
||||
photos = selectedCapture.capture.fieldPhotoCount,
|
||||
ready = selectedCapture.capture.readyForFinding,
|
||||
onPhoto = {
|
||||
val permissions = arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
)
|
||||
if (f3HasPermission(context, Manifest.permission.CAMERA) && f3HasLocation(context)) beginPhoto()
|
||||
else photoPermissionLauncher.launch(permissions)
|
||||
},
|
||||
onClose = { model.clearSelectedFieldAsset() },
|
||||
)
|
||||
}
|
||||
|
||||
if (mergeSource != null) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("¿Lo acabás de crear y ya existía?", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Podés fusionar esta alta de campo con el registro existente. La ficha nueva no se borra: queda como alias histórico con fecha, inspector e inspección.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = mergeSearch,
|
||||
onValueChange = { mergeSearch = it },
|
||||
label = { Text("Buscar posible duplicado") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton(onClick = {
|
||||
mergeCandidateId = null
|
||||
model.searchInventory(mergeSearch, mergeSource.asset.parent?.id)
|
||||
}) { Text("Buscar") }
|
||||
}
|
||||
if (mergeCandidates.isEmpty()) {
|
||||
Text("No hay coincidencias compatibles en esta búsqueda.", style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
mergeCandidates.take(8).forEach { candidate ->
|
||||
AssistChip(
|
||||
onClick = { mergeCandidateId = candidate.id },
|
||||
label = {
|
||||
Text(
|
||||
if (mergeCandidateId == candidate.id) "✓ ${candidate.code} · ${candidate.name}"
|
||||
else "${candidate.code} · ${candidate.name}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = mergeReason,
|
||||
onValueChange = { mergeReason = it },
|
||||
label = { Text("Motivo de la fusión") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val target = mergeCandidateId ?: return@Button
|
||||
model.mergeCreatedFieldAsset(target, mergeReason)
|
||||
},
|
||||
enabled = mergeCandidateId != null && mergeReason.trim().length >= 8 && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Fusionar y conservar el registro existente") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Buscar instalación o subinstalación") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { model.searchInventory(search) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Buscar") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
showCreate = !showCreate
|
||||
if (showCreate) model.loadFieldTypes(parentId)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreate) {
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.weight(1f, fill = false),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
|
||||
if (parentId != null) {
|
||||
OutlinedButton(onClick = {
|
||||
parentId = null
|
||||
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
|
||||
resetCreateForm()
|
||||
selectedTypeId = null
|
||||
model.loadFieldTypes(null)
|
||||
}) { Text("Volver al Yacimiento") }
|
||||
}
|
||||
|
||||
if (model.fieldTypes.isEmpty()) {
|
||||
Text("No hay un tipo disponible para esta ubicación.")
|
||||
} else {
|
||||
Text("Tipo de registro", style = MaterialTheme.typography.bodySmall)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(model.fieldTypes, key = { it.id }) { type ->
|
||||
AssistChip(
|
||||
onClick = {
|
||||
selectedTypeId = type.id
|
||||
selectedFamilyId = null
|
||||
attributeValues.clear()
|
||||
},
|
||||
label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedType?.familyRequired == true) {
|
||||
Text("Clasificación", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(selectedType.families, key = { it.id }) { family ->
|
||||
AssistChip(
|
||||
onClick = { selectedFamilyId = family.id },
|
||||
label = {
|
||||
val prefix = when {
|
||||
selectedFamilyId == family.id -> "✓ "
|
||||
family.isOther -> "+ "
|
||||
else -> ""
|
||||
}
|
||||
Text(prefix + family.name)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
selectedFamily?.let { family ->
|
||||
if (family.isOther) {
|
||||
Text(
|
||||
"Se registrará como no catalogado para revisión posterior en oficina.",
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (family.informationLabels.isNotEmpty()) {
|
||||
Text(
|
||||
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Nombre o código identificable *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = commonName,
|
||||
onValueChange = { commonName = it },
|
||||
label = { Text("Nombre habitual") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
selectedType?.attributes?.forEach { definition ->
|
||||
OutlinedTextField(
|
||||
value = attributeValues[definition.code].orEmpty(),
|
||||
onValueChange = { attributeValues[definition.code] = it },
|
||||
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
|
||||
supportingText = {
|
||||
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
|
||||
if (details.isNotBlank()) Text(details)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (
|
||||
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
|
||||
) KeyboardType.Decimal else KeyboardType.Text,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
val attributesReady = selectedType?.attributes
|
||||
?.filter { it.isRequired }
|
||||
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
|
||||
?: false
|
||||
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
||||
Button(
|
||||
onClick = {
|
||||
if (f3HasLocation(context)) createWithLocation()
|
||||
else locationPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Guardar alta y capturar GPS") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
"Instalaciones y subinstalaciones",
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(
|
||||
model.inventory.filter { candidate ->
|
||||
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
|
||||
},
|
||||
key = { it.id },
|
||||
) { item ->
|
||||
F3InventoryCard(
|
||||
item = item,
|
||||
onInspect = { model.selectExisting(item) },
|
||||
onUseParent = {
|
||||
parentId = item.id
|
||||
parentLabel = "${item.name} (${item.code})"
|
||||
showCreate = true
|
||||
selectedTypeId = null
|
||||
resetCreateForm()
|
||||
model.loadFieldTypes(item.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(30.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3CaptureCard(
|
||||
detailName: String,
|
||||
captureRequired: Boolean,
|
||||
gps: Boolean,
|
||||
photos: Int,
|
||||
ready: Boolean,
|
||||
onPhoto: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(detailName, fontWeight = FontWeight.Bold)
|
||||
OutlinedButton(onClick = onClose) { Text("Cerrar") }
|
||||
}
|
||||
if (captureRequired) {
|
||||
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
|
||||
if (!ready) {
|
||||
Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
|
||||
} else {
|
||||
Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
} else {
|
||||
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3InventoryCard(
|
||||
item: FieldInventoryItem,
|
||||
onInspect: () -> Unit,
|
||||
onUseParent: () -> Unit,
|
||||
) {
|
||||
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
|
||||
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
|
||||
val canHaveChild = typeCode == "instalacion"
|
||||
val childLabel = "+ Agregar subinstalación"
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(item.name, fontWeight = FontWeight.SemiBold)
|
||||
Text(structureLabel(typeCode), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(item.code, style = MaterialTheme.typography.bodySmall)
|
||||
item.commonName?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (canHaveFinding) {
|
||||
Text(
|
||||
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
|
||||
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
|
||||
}
|
||||
}
|
||||
if (canHaveChild) {
|
||||
OutlinedButton(onClick = onUseParent, modifier = Modifier.fillMaxWidth()) { Text(childLabel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3MessageStrip(model: MainViewModel) {
|
||||
val error = model.error
|
||||
val notice = model.notice
|
||||
if (error != null || notice != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
onClick = { model.clearMessages() },
|
||||
) {
|
||||
Text(error ?: notice.orEmpty(), Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun f3TypeCode(summary: com.korexlabs.dhinspeccion.data.AssetSummary): String =
|
||||
(summary.typeName ?: summary.name).trim().lowercase()
|
||||
.replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u')
|
||||
|
||||
private fun structureLabel(typeCode: String): String = when (typeCode) {
|
||||
"area" -> "Área"
|
||||
"yacimiento" -> "Yacimiento"
|
||||
"instalacion" -> "Instalación"
|
||||
"subinstalacion" -> "Subinstalación"
|
||||
else -> typeCode.ifBlank { "Inventario" }
|
||||
}
|
||||
|
||||
private fun statusLabel(status: String): String = when (status) {
|
||||
"PLANNED" -> "Planificada"
|
||||
"IN_PROGRESS" -> "En curso"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
}
|
||||
|
||||
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
||||
type.attributes.mapNotNull { definition ->
|
||||
val raw = values[definition.code]?.trim().orEmpty()
|
||||
if (raw.isBlank()) return@mapNotNull null
|
||||
definition.code to coerceF3Attribute(definition, raw)
|
||||
}.toMap()
|
||||
|
||||
private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
|
||||
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
|
||||
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
|
||||
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes")
|
||||
else -> raw
|
||||
}
|
||||
|
||||
private fun f3HasPermission(context: Context, permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun f3HasLocation(context: Context): Boolean =
|
||||
f3HasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
f3HasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
|
||||
private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCancellableCoroutine { continuation ->
|
||||
if (!f3HasLocation(context)) {
|
||||
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||
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) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
private fun newF3Photo(context: Context): Pair<File, Uri> {
|
||||
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
|
||||
directory.mkdirs()
|
||||
val file = File.createTempFile("DH_F3_${System.currentTimeMillis()}_", ".jpg", directory)
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
return file to uri
|
||||
}
|
||||
|
||||
private fun writeF3Exif(file: File, geo: F3GeoSnapshot) {
|
||||
val now = Instant.now()
|
||||
val exif = ExifInterface(file)
|
||||
exif.setLatLong(geo.latitude, geo.longitude)
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
|
||||
exif.saveAttributes()
|
||||
}
|
||||
|
||||
private fun f3ShortDate(value: String): String = value.replace('T', ' ').take(16)
|
||||
@@ -1,425 +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.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.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.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 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 FindingGeoSnapshot(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyM: Double?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun FieldFindingScreen(model: MainViewModel) {
|
||||
val options = model.fieldFindingOptions ?: return
|
||||
val asset = model.selectedFieldAsset?.asset ?: return
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var search by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
|
||||
var other by rememberSaveable(asset.id) { mutableStateOf(false) }
|
||||
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var description by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
|
||||
|
||||
var requestedFindingId by remember { mutableStateOf<String?>(null) }
|
||||
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
|
||||
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
|
||||
var pendingPhotoFindingId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
val file = pendingPhotoFile
|
||||
val geo = pendingPhotoGeo
|
||||
val findingId = pendingPhotoFindingId
|
||||
if (success && file != null && geo != null && findingId != null) {
|
||||
runCatching { writeFindingExif(file, geo) }
|
||||
model.uploadFindingPhoto(
|
||||
findingId = findingId,
|
||||
file = file,
|
||||
latitude = geo.latitude,
|
||||
longitude = geo.longitude,
|
||||
accuracyM = geo.accuracyM,
|
||||
title = "Evidencia fotográfica de campo",
|
||||
)
|
||||
}
|
||||
pendingPhotoFile = null
|
||||
pendingPhotoGeo = null
|
||||
pendingPhotoFindingId = null
|
||||
}
|
||||
|
||||
fun beginPhoto(findingId: String) {
|
||||
scope.launch {
|
||||
runCatching { currentFindingGeo(context) }
|
||||
.onSuccess { geo ->
|
||||
val (file, uri) = newFindingPhoto(context)
|
||||
pendingPhotoFile = file
|
||||
pendingPhotoGeo = geo
|
||||
pendingPhotoFindingId = findingId
|
||||
takePicture.launch(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val photoPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { result ->
|
||||
val camera = result[Manifest.permission.CAMERA] == true || findingHasPermission(context, Manifest.permission.CAMERA)
|
||||
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || findingHasLocation(context)
|
||||
val findingId = requestedFindingId
|
||||
requestedFindingId = null
|
||||
if (camera && location && findingId != null) beginPhoto(findingId)
|
||||
}
|
||||
|
||||
fun requestPhoto(findingId: String) {
|
||||
requestedFindingId = findingId
|
||||
if (findingHasPermission(context, Manifest.permission.CAMERA) && findingHasLocation(context)) {
|
||||
requestedFindingId = null
|
||||
beginPhoto(findingId)
|
||||
} else {
|
||||
photoPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val selected = options.catalog.items.firstOrNull { it.id == selectedCatalogId }
|
||||
val filtered = options.catalog.items.filter {
|
||||
search.isBlank() ||
|
||||
it.title.contains(search, ignoreCase = true) ||
|
||||
it.code.contains(search, ignoreCase = true) ||
|
||||
it.categoryName.orEmpty().contains(search, ignoreCase = true)
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedCatalogId, other) {
|
||||
if (!other && selected != null && severityText.isBlank() && selected.suggestedSeverity != null) {
|
||||
severityText = selected.suggestedSeverity.toString()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(model.lastCreatedFinding?.id) {
|
||||
if (model.lastCreatedFinding != null) {
|
||||
selectedCatalogId = null
|
||||
other = false
|
||||
customTitle = ""
|
||||
customLegalBasis = ""
|
||||
description = ""
|
||||
severityText = ""
|
||||
correctionDueOn = ""
|
||||
search = ""
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 30.dp, start = 16.dp, end = 16.dp, bottom = 36.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
|
||||
Text("Volver")
|
||||
}
|
||||
Text("Hallazgo de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(asset.name, fontWeight = FontWeight.Bold)
|
||||
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}",
|
||||
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
model.error?.let {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
|
||||
Text(it, Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
model.notice?.let {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
|
||||
Text(it, Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (options.findings.isNotEmpty()) {
|
||||
Text("Hallazgos registrados en este Inventario", fontWeight = FontWeight.Bold)
|
||||
options.findings.forEach { finding ->
|
||||
val evidence = model.fieldFindingEvidence[finding.id].orEmpty()
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
|
||||
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
evidence.take(3).forEach { item ->
|
||||
Text(
|
||||
"• ${item.title ?: item.originalName}${item.capturedAt?.let { " · ${shortFindingDate(it)}" }.orEmpty()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { requestPhoto(finding.id) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Tomar foto con GPS")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
Text("1. Elegí el tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (!options.catalog.typeConfigured) {
|
||||
Text(
|
||||
options.catalog.configurationReason
|
||||
?: "Este tipo de Inventario todavía no tiene un catálogo contextual configurado. Podés usar OTROS.",
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Buscar en catálogo") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
filtered.forEach { item ->
|
||||
val chosen = !other && selectedCatalogId == item.id
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable {
|
||||
selectedCatalogId = item.id
|
||||
other = false
|
||||
severityText = item.suggestedSeverity?.toString().orEmpty()
|
||||
},
|
||||
colors = if (chosen) {
|
||||
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||
} else {
|
||||
CardDefaults.cardColors()
|
||||
},
|
||||
) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(if (chosen) "✓ ${item.title}" else item.title, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
listOfNotNull(item.categoryName, item.code, item.suggestedSeverity?.let { "Gravedad sugerida $it" })
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
other = true
|
||||
selectedCatalogId = null
|
||||
severityText = ""
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (other) "✓ OTROS · Hallazgo no catalogado" else "OTROS · No está en el catálogo")
|
||||
}
|
||||
if (other) {
|
||||
options.catalog.other.help?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
OutlinedTextField(
|
||||
value = customTitle,
|
||||
onValueChange = { customTitle = it },
|
||||
label = { Text("Título del nuevo Hallazgo *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = customLegalBasis,
|
||||
onValueChange = { customLegalBasis = it },
|
||||
label = { Text("Base legal / normativa (opcional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Text("2. Describí lo observado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
selected?.let {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(it.title, fontWeight = FontWeight.SemiBold)
|
||||
it.legalBasis?.takeIf(String::isNotBlank)?.let { basis -> Text(basis, style = MaterialTheme.typography.bodySmall) }
|
||||
it.glossary?.takeIf(String::isNotBlank)?.let { glossary -> Text(glossary, style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Descripción del Hallazgo *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = severityText,
|
||||
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
|
||||
label = { Text("Gravedad 1 a 10") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = correctionDueOn,
|
||||
onValueChange = { correctionDueOn = it },
|
||||
label = { Text("Fecha de corrección AAAA-MM-DD (opcional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
val severity = severityText.toIntOrNull()
|
||||
val choiceReady = selectedCatalogId != null || (other && customTitle.isNotBlank())
|
||||
Button(
|
||||
onClick = {
|
||||
model.createFieldFinding(
|
||||
catalogItemId = if (other) null else selectedCatalogId,
|
||||
customTitle = if (other) customTitle else null,
|
||||
customLegalBasis = if (other) customLegalBasis else null,
|
||||
description = description,
|
||||
severity = severity,
|
||||
correctionDueOn = correctionDueOn,
|
||||
)
|
||||
},
|
||||
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (model.busy) "Guardando…" else "Guardar Hallazgo")
|
||||
}
|
||||
|
||||
model.lastCreatedFinding?.let { finding ->
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Hallazgo registrado", fontWeight = FontWeight.Bold)
|
||||
Text("${finding.code} · ${finding.title}")
|
||||
Button(
|
||||
onClick = { requestPhoto(finding.id) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Tomar foto con GPS")
|
||||
}
|
||||
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findingHasPermission(context: Context, permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun findingHasLocation(context: Context): Boolean =
|
||||
findingHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
|
||||
private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = suspendCancellableCoroutine { continuation ->
|
||||
if (!findingHasLocation(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(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() }
|
||||
}
|
||||
|
||||
private fun newFindingPhoto(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_HALLAZGO_${System.currentTimeMillis()}_", ".jpg", directory)
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
return file to uri
|
||||
}
|
||||
|
||||
private fun writeFindingExif(file: File, geo: FindingGeoSnapshot) {
|
||||
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 shortFindingDate(value: String): String = value.replace('T', ' ').take(16)
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import com.korexlabs.dhinspeccion.data.FieldType
|
||||
|
||||
/**
|
||||
* Compatibilidad transitoria con la pantalla F2.x que queda compilada pero ya no
|
||||
* será la ruta activa en F3.1. El flujo nuevo siempre envía familyId cuando el
|
||||
* nivel estructural lo requiere.
|
||||
*/
|
||||
fun MainViewModel.createFieldAsset(
|
||||
type: FieldType,
|
||||
parentId: String?,
|
||||
name: String,
|
||||
commonName: String?,
|
||||
attributes: Map<String, Any?>,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
accuracyM: Double?,
|
||||
) = createFieldAsset(
|
||||
type = type,
|
||||
parentId = parentId,
|
||||
familyId = null,
|
||||
name = name,
|
||||
commonName = commonName,
|
||||
attributes = attributes,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
accuracyM = accuracyM,
|
||||
)
|
||||
@@ -1,208 +0,0 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.remember
|
||||
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.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
|
||||
private const val BIOMETRIC_PREFS = "dh_v2_biometric"
|
||||
private const val BIOMETRIC_ENABLED = "enabled"
|
||||
|
||||
private fun biometricAvailable(context: Context): Boolean =
|
||||
BiometricManager.from(context).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
|
||||
BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
private fun biometricEnabled(context: Context): Boolean =
|
||||
context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE)
|
||||
.getBoolean(BIOMETRIC_ENABLED, false)
|
||||
|
||||
private fun setBiometricEnabled(context: Context, enabled: Boolean) {
|
||||
context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(BIOMETRIC_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
|
||||
val context = LocalContext.current
|
||||
val capable = remember { biometricAvailable(context) }
|
||||
var unlocked by rememberSaveable { mutableStateOf(false) }
|
||||
var passwordLoginInFlight by rememberSaveable { mutableStateOf(false) }
|
||||
var enabled by remember { mutableStateOf(biometricEnabled(context)) }
|
||||
|
||||
LaunchedEffect(model.session) {
|
||||
if (model.session != null && passwordLoginInFlight) {
|
||||
if (capable) {
|
||||
setBiometricEnabled(context, true)
|
||||
enabled = true
|
||||
}
|
||||
unlocked = true
|
||||
passwordLoginInFlight = false
|
||||
}
|
||||
if (model.session == null) unlocked = false
|
||||
}
|
||||
|
||||
MaterialTheme {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
when {
|
||||
model.session == null -> EnhancedLoginScreen(model) {
|
||||
passwordLoginInFlight = true
|
||||
}
|
||||
enabled && capable && !unlocked -> BiometricUnlockScreen(
|
||||
activity = activity,
|
||||
displayName = model.session?.displayName.orEmpty(),
|
||||
onAuthenticated = { unlocked = true },
|
||||
onUsePassword = {
|
||||
setBiometricEnabled(context, false)
|
||||
enabled = false
|
||||
model.logout()
|
||||
},
|
||||
)
|
||||
model.fieldFindingOptions != null -> FieldFindingScreen(model)
|
||||
model.visit != null -> F3VisitRoot(model)
|
||||
else -> MobileHomeScreen(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
|
||||
var identifier by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Aplicación de campo · Dirección de Hidrocarburos")
|
||||
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||
OutlinedTextField(
|
||||
value = identifier,
|
||||
onValueChange = { identifier = it },
|
||||
label = { Text("Usuario o email") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Contraseña") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = if (passwordVisible) "Ocultar contraseña" else "Mostrar contraseña",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
onPasswordLogin()
|
||||
model.login(identifier, password)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !model.busy,
|
||||
) { Text(if (model.busy) "Ingresando…" else "Ingresar") }
|
||||
Text(
|
||||
"Después del primer ingreso correcto, si la tablet tiene una huella fuerte configurada, se habilita el acceso biométrico. La contraseña no se almacena.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BiometricUnlockScreen(
|
||||
activity: FragmentActivity,
|
||||
displayName: String,
|
||||
onAuthenticated: () -> Unit,
|
||||
onUsePassword: () -> Unit,
|
||||
) {
|
||||
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
fun authenticate() {
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
val prompt = BiometricPrompt(
|
||||
activity,
|
||||
executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
error = null
|
||||
onAuthenticated()
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
error = errString.toString()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
error = "Huella no reconocida."
|
||||
}
|
||||
},
|
||||
)
|
||||
val info = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Ingresar a DH Inspección")
|
||||
.setSubtitle(if (displayName.isBlank()) "Validá tu identidad" else "Hola, $displayName")
|
||||
.setNegativeButtonText("Usar contraseña")
|
||||
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||
.build()
|
||||
prompt.authenticate(info)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { authenticate() }
|
||||
|
||||
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Ingresá con tu huella")
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Button(onClick = { authenticate() }, modifier = Modifier.fillMaxWidth()) { Text("Usar huella") }
|
||||
OutlinedButton(onClick = onUsePassword, modifier = Modifier.fillMaxWidth()) { Text("Ingresar con contraseña") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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 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 ActSignatureGeo(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyM: Double?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun MobileActsScreen(
|
||||
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 { currentActSignatureGeo(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(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 36.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
OutlinedButton(onClick = onBack, enabled = !model.busy) { Text("Volver") }
|
||||
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
|
||||
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
|
||||
F32ActMessage(model)
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
|
||||
if (model.acts.isEmpty()) {
|
||||
Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.")
|
||||
}
|
||||
model.acts.forEach { act ->
|
||||
val active = selected?.id == act.id
|
||||
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
|
||||
OutlinedButton(
|
||||
onClick = { model.selectAct(act.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
(if (active) "✓ " else "") +
|
||||
"${act.code} · ${actStatusLabel(act.status)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
|
||||
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (model.acts.any { it.status == "LOCKED" }) {
|
||||
Text(
|
||||
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text("Urgencia", fontWeight = FontWeight.Bold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(
|
||||
onClick = { newActUrgency = "NON_URGENT" },
|
||||
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
|
||||
)
|
||||
AssistChip(
|
||||
onClick = { newActUrgency = "URGENT" },
|
||||
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (newActUrgency == "URGENT") {
|
||||
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
|
||||
} else {
|
||||
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
"Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.createAct(newActUrgency) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Crear nueva Acta") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected != null) {
|
||||
HorizontalDivider()
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(actStatusLabel(selected.status))
|
||||
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
|
||||
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
|
||||
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
|
||||
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
|
||||
when (selected.status) {
|
||||
"DRAFT" -> {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("+ Agregar Hallazgo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
|
||||
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
|
||||
}
|
||||
if (attendance == "PRESENT") {
|
||||
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth())
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
|
||||
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "✓ $kind" else kind) })
|
||||
}
|
||||
}
|
||||
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth())
|
||||
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") }
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Finalizar y BLOQUEAR Acta") }
|
||||
}
|
||||
|
||||
"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"
|
||||
|
||||
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||
if (inspectorSigned) {
|
||||
Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary)
|
||||
} 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 -> companyOutcome.status
|
||||
}
|
||||
Text("✓ $detail", color = MaterialTheme.colorScheme.primary)
|
||||
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
||||
Text(
|
||||
"El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
|
||||
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "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 la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", 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") }
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
if (inspectorSigned && companyResolved) {
|
||||
Button(
|
||||
onClick = { model.closeSelectedAct() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("SELLAR Acta definitivamente") }
|
||||
} else {
|
||||
Text(
|
||||
"Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"SEALED" -> {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
|
||||
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
|
||||
closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"CANCELLED" -> Text("Esta Acta fue cancelada y permanece sólo como antecedente.")
|
||||
}
|
||||
}
|
||||
|
||||
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
val activeActs = model.acts.filter { it.status != "CANCELLED" }
|
||||
val pendingActs = activeActs.filter { it.status != "SEALED" }
|
||||
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (pendingActs.isEmpty()) {
|
||||
Text("Todas las Actas están SELLADAS. La Inspección ya puede cerrarse.")
|
||||
} else {
|
||||
Text("No puede cerrarse todavía. Falta SELLAR: ${pendingActs.joinToString { it.code }}")
|
||||
}
|
||||
Button(
|
||||
onClick = { model.closeInspection() },
|
||||
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Inspección y salir de la empresa") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F32ActMessage(model: MainViewModel) {
|
||||
val text = model.error ?: model.notice ?: return
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (model.error != null) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
onClick = { model.clearMessages() },
|
||||
) {
|
||||
Text(text, Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun actStatusLabel(status: String): String = when (status) {
|
||||
"DRAFT" -> "Borrador"
|
||||
"LOCKED" -> "BLOQUEADA"
|
||||
"SEALED" -> "SELLADA"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
}
|
||||
|
||||
private fun hasActLocation(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 currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation ->
|
||||
if (!hasActLocation(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(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() }
|
||||
}
|
||||
@@ -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(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,137 +0,0 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas as AndroidCanvas
|
||||
import android.graphics.Color as AndroidColor
|
||||
import android.graphics.Paint
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@Composable
|
||||
fun SignaturePad(
|
||||
label: String,
|
||||
enabled: Boolean = true,
|
||||
onCaptured: (File) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val strokes = remember { mutableStateListOf<List<Offset>>() }
|
||||
var currentStroke by remember { mutableStateOf<List<Offset>>(emptyList()) }
|
||||
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
|
||||
Text(
|
||||
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(190.dp)
|
||||
.background(Color.White)
|
||||
.onSizeChanged { canvasSize = it }
|
||||
.pointerInput(enabled) {
|
||||
if (!enabled) return@pointerInput
|
||||
detectDragGestures(
|
||||
onDragStart = { position -> currentStroke = listOf(position) },
|
||||
onDrag = { change, _ ->
|
||||
change.consume()
|
||||
currentStroke = currentStroke + change.position
|
||||
},
|
||||
onDragEnd = {
|
||||
if (currentStroke.size > 1) strokes.add(currentStroke)
|
||||
currentStroke = emptyList()
|
||||
},
|
||||
onDragCancel = { currentStroke = emptyList() },
|
||||
)
|
||||
},
|
||||
) {
|
||||
val all = strokes + listOf(currentStroke)
|
||||
all.forEach { stroke ->
|
||||
stroke.zipWithNext().forEach { (start, end) ->
|
||||
drawLine(
|
||||
color = Color.Black,
|
||||
start = start,
|
||||
end = end,
|
||||
strokeWidth = 4.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { strokes.clear(); currentStroke = emptyList() },
|
||||
enabled = enabled && (strokes.isNotEmpty() || currentStroke.isNotEmpty()),
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Limpiar") }
|
||||
Button(
|
||||
onClick = {
|
||||
val width = canvasSize.width.coerceAtLeast(1)
|
||||
val height = canvasSize.height.coerceAtLeast(1)
|
||||
val targetWidth = 1000
|
||||
val targetHeight = 400
|
||||
val scaleX = targetWidth.toFloat() / width.toFloat()
|
||||
val scaleY = targetHeight.toFloat() / height.toFloat()
|
||||
val bitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
|
||||
val native = AndroidCanvas(bitmap)
|
||||
native.drawColor(AndroidColor.WHITE)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = AndroidColor.BLACK
|
||||
strokeWidth = 7f
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeJoin = Paint.Join.ROUND
|
||||
style = Paint.Style.STROKE
|
||||
}
|
||||
strokes.forEach { stroke ->
|
||||
stroke.zipWithNext().forEach { (start, end) ->
|
||||
native.drawLine(
|
||||
start.x * scaleX,
|
||||
start.y * scaleY,
|
||||
end.x * scaleX,
|
||||
end.y * scaleY,
|
||||
paint,
|
||||
)
|
||||
}
|
||||
}
|
||||
val file = File.createTempFile("DH_FIRMA_", ".png", context.cacheDir)
|
||||
FileOutputStream(file).use { stream ->
|
||||
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream))
|
||||
}
|
||||
bitmap.recycle()
|
||||
onCaptured(file)
|
||||
},
|
||||
enabled = enabled && strokes.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Usar firma") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">DH Inspección</string>
|
||||
</resources>
|
||||
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.DHInspeccion" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowActionModeOverlay">true</item>
|
||||
<item name="android:colorAccent">#1B5E20</item>
|
||||
<item name="android:navigationBarColor">#FFFFFF</item>
|
||||
<item name="android:statusBarColor">#FFFFFF</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-files-path name="inspection_photos" path="Pictures/" />
|
||||
</paths>
|
||||
@@ -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(25, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.16.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://"))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -1,18 +0,0 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "DHInspeccion"
|
||||
include(":app")
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.git
|
||||
*.log
|
||||
@@ -1,19 +0,0 @@
|
||||
FROM node:24-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY nest-cli.json tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
RUN apk add --no-cache unzip
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=builder /app/dist ./dist
|
||||
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
-6267
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.29.0-1",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "nest start --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc -p tsconfig.test.json --noEmit && node --import tsx --test test/**/*.test.ts",
|
||||
"migration:run": "node dist/database/migration-cli.js run",
|
||||
"migration:show": "node dist/database/migration-cli.js show",
|
||||
"migration:revert": "node dist/database/migration-cli.js revert",
|
||||
"bootstrap:admin": "node dist/cli/bootstrap-admin.js",
|
||||
"dev:seed:mendoza-demo": "node dist/cli/dev-seed-mendoza-demo.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"argon2": "^0.45.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"helmet": "^8.0.0",
|
||||
"pg": "^8.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0",
|
||||
"typeorm": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.0"
|
||||
},
|
||||
"overrides": {
|
||||
"multer": "2.3.0",
|
||||
"qs": "6.16.0"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service';
|
||||
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
|
||||
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
||||
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
|
||||
|
||||
@Controller('act-administration')
|
||||
export class ActAdministrationQueueController {
|
||||
constructor(private readonly administration: ActAdministrationService) {}
|
||||
@Get('queue') @RequirePermissions('inspection_acts.read') queue(@Query() query: ListActAdministrationQueryDto) { return this.administration.queue(query); }
|
||||
@Get('calendar') @RequirePermissions('inspection_acts.read') calendar(@Query('from') from?: string, @Query('to') to?: string) { return this.administration.calendar(from, to); }
|
||||
}
|
||||
|
||||
@Controller('inspection-acts/:actId/administration')
|
||||
export class ActAdministrationController {
|
||||
constructor(private readonly administration: ActAdministrationService) {}
|
||||
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); }
|
||||
@Patch('deadline') @RequirePermissions('inspection_findings.follow_up')
|
||||
setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); }
|
||||
@Post('responses') @RequirePermissions('inspection_findings.follow_up')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } }))
|
||||
addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); }
|
||||
}
|
||||
|
||||
@Controller('act-company-responses')
|
||||
export class ActCompanyResponseContentController {
|
||||
constructor(private readonly administration: ActAdministrationService) {}
|
||||
@Get(':responseId/content') @RequirePermissions('inspection_acts.read')
|
||||
async content(@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string, @Query('download') download: string | undefined, @Res() response: Response): Promise<void> {
|
||||
const item = await this.administration.responseContent(responseId);
|
||||
const disposition = download === '1' ? 'attachment' : 'inline';
|
||||
const fallbackName = item.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
|
||||
response.setHeader('Content-Type', 'application/pdf');
|
||||
response.setHeader('Content-Length', String(item.sizeBytes));
|
||||
response.setHeader('Content-Disposition', `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
response.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
|
||||
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import {
|
||||
ActAdministrationController,
|
||||
ActAdministrationQueueController,
|
||||
ActCompanyResponseContentController,
|
||||
} from './act-administration.controller';
|
||||
import { ActAdministrationService } from './act-administration.service';
|
||||
import { FieldBriefingController } from './field-briefing.controller';
|
||||
import { FieldBriefingService } from './field-briefing.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [
|
||||
ActAdministrationQueueController,
|
||||
ActAdministrationController,
|
||||
ActCompanyResponseContentController,
|
||||
FieldBriefingController,
|
||||
],
|
||||
providers: [ActAdministrationService, FieldBriefingService],
|
||||
})
|
||||
export class ActAdministrationModule {}
|
||||
@@ -1,180 +0,0 @@
|
||||
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { mkdir, stat, unlink, writeFile } from 'fs/promises';
|
||||
import { join, resolve } from 'path';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
|
||||
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
|
||||
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
|
||||
|
||||
export const MAX_ACT_RESPONSE_BYTES = 15 * 1024 * 1024;
|
||||
export interface UploadedActResponseFile { originalname: string; mimetype: string; size: number; buffer: Buffer }
|
||||
|
||||
const STORAGE = resolve(process.env.ACT_COMPANY_RESPONSE_STORAGE_DIR ?? '/app/storage/act-company-responses');
|
||||
|
||||
@Injectable()
|
||||
export class ActAdministrationService {
|
||||
constructor(private readonly dataSource: DataSource, private readonly audit: AuditService) {}
|
||||
|
||||
private baseCte() {
|
||||
return `
|
||||
WITH act_rows AS (
|
||||
SELECT ia.id AS "actId", ia.code AS "actCode", ia.status AS "actStatus", ia.occurred_at AS "occurredAt",
|
||||
ia.closed_at AS "closedAt", v.id AS "visitId", v.code AS "visitCode",
|
||||
v.operational_area_id AS "areaId", v.operator_company_id AS "companyId",
|
||||
area.name AS "areaName", COALESCE(op.legal_name, company.name) AS "companyName",
|
||||
dl.id AS "deadlineEventId", dl.response_due_on AS "responseDueOn", dl.reason AS "deadlineReason",
|
||||
dl.created_at AS "deadlineSetAt", rsp.id AS "latestResponseId", rsp.received_on AS "responseReceivedOn",
|
||||
rsp.committed_correction_on AS "committedCorrectionOn",
|
||||
COALESCE(fc.finding_count, 0)::int AS "findingCount",
|
||||
COALESCE(fc.open_count, 0)::int AS "openFindingCount",
|
||||
COALESCE(fc.scheduled_control_count, 0)::int AS "scheduledControlCount"
|
||||
FROM inspection_acts ia
|
||||
JOIN inspection_visits v ON v.id = ia.visit_id
|
||||
LEFT JOIN assets area ON area.id = v.operational_area_id
|
||||
LEFT JOIN assets company ON company.id = v.operator_company_id
|
||||
LEFT JOIN organization_profiles op ON op.asset_id = v.operator_company_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT d.* FROM inspection_act_deadline_events d WHERE d.act_id = ia.id ORDER BY d.created_at DESC, d.id DESC LIMIT 1
|
||||
) dl ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT r.* FROM inspection_act_company_responses r WHERE r.act_id = ia.id ORDER BY r.received_on DESC, r.created_at DESC, r.id DESC LIMIT 1
|
||||
) rsp ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) FILTER (WHERE f.status <> 'VOIDED') AS finding_count,
|
||||
COUNT(*) FILTER (WHERE f.status = 'OPEN') AS open_count,
|
||||
COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count
|
||||
FROM inspection_findings f WHERE f.act_id = ia.id
|
||||
) fc ON true
|
||||
WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
|
||||
), classified AS (
|
||||
SELECT *, CASE
|
||||
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED'
|
||||
WHEN "latestResponseId" IS NOT NULL AND "committedCorrectionOn" IS NOT NULL AND "committedCorrectionOn" < CURRENT_DATE AND "openFindingCount" > 0 THEN 'COMMITMENT_OVERDUE'
|
||||
WHEN "latestResponseId" IS NOT NULL AND "openFindingCount" > 0 AND "scheduledControlCount" = 0 THEN 'VERIFICATION_PENDING'
|
||||
WHEN "latestResponseId" IS NOT NULL THEN 'RESPONSE_RECEIVED'
|
||||
WHEN "responseDueOn" IS NULL THEN 'NEW'
|
||||
WHEN "responseDueOn" < CURRENT_DATE THEN 'OVERDUE'
|
||||
WHEN "responseDueOn" <= CURRENT_DATE + 3 THEN 'DUE_SOON'
|
||||
ELSE 'WAITING_RESPONSE'
|
||||
END AS "adminState"
|
||||
FROM act_rows
|
||||
)`;
|
||||
}
|
||||
|
||||
async queue(query: ListActAdministrationQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 25;
|
||||
const filters: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
const add = (sql: string, value: unknown) => { args.push(value); filters.push(sql.replace('?', `$${args.length}`)); };
|
||||
if (query.areaId) add('"areaId" = ?', query.areaId);
|
||||
if (query.companyId) add('"companyId" = ?', query.companyId);
|
||||
if (query.state && query.state !== 'ALL') add('"adminState" = ?', query.state);
|
||||
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
const rows = await this.dataSource.query(
|
||||
`${this.baseCte()} SELECT *, COUNT(*) OVER()::int AS "totalRows" FROM classified ${where} ORDER BY "responseDueOn" ASC NULLS FIRST, "occurredAt" DESC LIMIT $${args.length + 1} OFFSET $${args.length + 2}`,
|
||||
[...args, pageSize, (page - 1) * pageSize],
|
||||
) as Array<Record<string, unknown>>;
|
||||
const total = Number(rows[0]?.totalRows ?? 0);
|
||||
const counterFilters: string[] = [];
|
||||
const counterArgs: unknown[] = [];
|
||||
if (query.areaId) { counterArgs.push(query.areaId); counterFilters.push(`"areaId" = $${counterArgs.length}`); }
|
||||
if (query.companyId) { counterArgs.push(query.companyId); counterFilters.push(`"companyId" = $${counterArgs.length}`); }
|
||||
const countersRaw = await this.dataSource.query(
|
||||
`${this.baseCte()} SELECT "adminState" AS state, COUNT(*)::int AS count FROM classified ${counterFilters.length ? `WHERE ${counterFilters.join(' AND ')}` : ''} GROUP BY "adminState"`,
|
||||
counterArgs,
|
||||
) as Array<{ state: string; count: number }>;
|
||||
const counters = Object.fromEntries(countersRaw.map((r) => [r.state, Number(r.count)]));
|
||||
return {
|
||||
data: rows.map(({ totalRows: _ignored, ...row }) => row),
|
||||
counters,
|
||||
meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) },
|
||||
};
|
||||
}
|
||||
|
||||
async detail(actId: string) {
|
||||
const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE "actId" = $1`, [actId]);
|
||||
const act = rows[0];
|
||||
if (!act) throw new NotFoundException({ code: 'ACT_ADMIN_NOT_FOUND', message: 'El Acta no está disponible para seguimiento administrativo.' });
|
||||
const deadlines = await this.dataSource.query(`SELECT id, response_due_on AS "responseDueOn", reason, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_deadline_events WHERE act_id = $1 ORDER BY created_at DESC`, [actId]);
|
||||
const responses = await this.dataSource.query(`SELECT id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", mime_type AS "mimeType", size_bytes AS "sizeBytes", sha256, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_company_responses WHERE act_id = $1 ORDER BY received_on DESC, created_at DESC`, [actId]);
|
||||
const findings = await this.dataSource.query(`SELECT id, code, title, status, next_control_on AS "nextControlOn" FROM inspection_findings WHERE act_id = $1 AND status <> 'VOIDED' ORDER BY finding_number`, [actId]);
|
||||
return { act, deadlines, responses, findings };
|
||||
}
|
||||
|
||||
private async ensureClosedAct(actId: string) {
|
||||
const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
|
||||
const act = rows[0];
|
||||
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' });
|
||||
if (!['SEALED', 'CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_SEALED', message: 'El seguimiento administrativo comienza cuando el Acta está sellada.' });
|
||||
return act;
|
||||
}
|
||||
|
||||
async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) {
|
||||
const act = await this.ensureClosedAct(actId);
|
||||
const rows = await this.dataSource.query(
|
||||
`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`,
|
||||
[randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId],
|
||||
);
|
||||
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } });
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) {
|
||||
const act = await this.ensureClosedAct(actId);
|
||||
let storedName: string | null = null;
|
||||
let sha256: string | null = null;
|
||||
if (file) {
|
||||
if (file.size <= 0 || file.size > MAX_ACT_RESPONSE_BYTES) throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_SIZE', message: 'El PDF supera el límite permitido.' });
|
||||
if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_TYPE', message: 'La respuesta adjunta debe ser un PDF válido.' });
|
||||
await mkdir(STORAGE, { recursive: true });
|
||||
sha256 = createHash('sha256').update(file.buffer).digest('hex');
|
||||
storedName = `${randomUUID()}.pdf`;
|
||||
await writeFile(join(STORAGE, storedName), file.buffer, { flag: 'wx' });
|
||||
}
|
||||
try {
|
||||
const rows = await this.dataSource.query(
|
||||
`INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`,
|
||||
[randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId],
|
||||
);
|
||||
await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } });
|
||||
return rows[0];
|
||||
} catch (error) {
|
||||
if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async responseContent(responseId: string) {
|
||||
const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]);
|
||||
const row = rows[0];
|
||||
if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' });
|
||||
const filePath = join(STORAGE, row.storedName);
|
||||
const info = await stat(filePath).catch(() => null);
|
||||
if (!info?.isFile()) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'El archivo de respuesta no está disponible.' });
|
||||
return { originalName: row.originalName as string, sizeBytes: Number(row.sizeBytes), filePath };
|
||||
}
|
||||
|
||||
async calendar(from?: string, to?: string) {
|
||||
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if ((from && !dateRe.test(from)) || (to && !dateRe.test(to))) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'Las fechas deben usar YYYY-MM-DD.' });
|
||||
const start = from ?? new Date().toISOString().slice(0, 10);
|
||||
const endDate = new Date(`${to ?? start}T00:00:00Z`);
|
||||
if (!to) endDate.setUTCDate(endDate.getUTCDate() + 60);
|
||||
const end = endDate.toISOString().slice(0, 10);
|
||||
if (start > end) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'El rango de fechas es inválido.' });
|
||||
const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE ("responseDueOn" BETWEEN $1 AND $2) OR ("committedCorrectionOn" BETWEEN $1 AND $2) ORDER BY COALESCE("responseDueOn", "committedCorrectionOn")`, [start, end]);
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
for (const row of rows) {
|
||||
if (row.responseDueOn && row.responseDueOn >= start && row.responseDueOn <= end) events.push({ type: 'RESPONSE_DUE', date: row.responseDueOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState });
|
||||
if (row.committedCorrectionOn && row.committedCorrectionOn >= start && row.committedCorrectionOn <= end) events.push({ type: 'COMMITMENT_DUE', date: row.committedCorrectionOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState });
|
||||
}
|
||||
return { from: start, to: end, data: events.sort((a, b) => String(a.date).localeCompare(String(b.date))) };
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateActCompanyResponseDto {
|
||||
@IsDateString()
|
||||
receivedOn!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(8000)
|
||||
details?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
committedCorrectionOn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
contactName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsEmail()
|
||||
@MaxLength(320)
|
||||
contactEmail?: string;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
|
||||
|
||||
export const ACT_ADMIN_STATES = [
|
||||
'ALL', 'NEW', 'WAITING_RESPONSE', 'DUE_SOON', 'OVERDUE',
|
||||
'RESPONSE_RECEIVED', 'VERIFICATION_PENDING', 'COMMITMENT_OVERDUE', 'REGULARIZED',
|
||||
] as const;
|
||||
export type ActAdministrationState = typeof ACT_ADMIN_STATES[number];
|
||||
|
||||
export class ListActAdministrationQueryDto {
|
||||
@IsOptional() @IsIn(ACT_ADMIN_STATES) state?: ActAdministrationState;
|
||||
@IsOptional() @IsUUID('4') areaId?: string;
|
||||
@IsOptional() @IsUUID('4') companyId?: string;
|
||||
@IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) page?: number;
|
||||
@IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) @Max(100) pageSize?: number;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SetActResponseDeadlineDto {
|
||||
@IsDateString()
|
||||
responseDueOn!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(1000)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { FieldBriefingService } from './field-briefing.service';
|
||||
|
||||
@Controller('inspection-visits/:visitId/field-briefing')
|
||||
export class FieldBriefingController {
|
||||
constructor(private readonly briefing: FieldBriefingService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspections.read')
|
||||
get(@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string) {
|
||||
return this.briefing.forVisit(visitId);
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export type FieldBriefingActState =
|
||||
| 'ACT_RESPONSE_OVERDUE'
|
||||
| 'ACT_RESPONSE_DUE_SOON'
|
||||
| 'WAITING_RESPONSE'
|
||||
| 'COMPANY_COMMITMENT_OVERDUE'
|
||||
| 'VERIFICATION_PENDING'
|
||||
| 'RESPONSE_RECEIVED';
|
||||
|
||||
export interface FieldBriefingFinding {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: string;
|
||||
severity: number | null;
|
||||
nextControlOn: string | null;
|
||||
asset: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
typeName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FieldBriefingAct {
|
||||
actId: string;
|
||||
actCode: string;
|
||||
occurredAt: Date;
|
||||
adminState: FieldBriefingActState;
|
||||
responseDueOn: string | null;
|
||||
responseReceivedOn: string | null;
|
||||
committedCorrectionOn: string | null;
|
||||
latestResponseId: string | null;
|
||||
findings: FieldBriefingFinding[];
|
||||
}
|
||||
|
||||
function toIsoDate(value: unknown): string {
|
||||
const parsed = value instanceof Date
|
||||
? value
|
||||
: value == null
|
||||
? new Date()
|
||||
: new Date(String(value));
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new Error('La fecha planificada de la inspección no es válida');
|
||||
}
|
||||
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FieldBriefingService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async forVisit(visitId: string) {
|
||||
const [visit] = (await this.dataSource.query(`
|
||||
SELECT
|
||||
visit.id,
|
||||
visit.code,
|
||||
visit.status,
|
||||
visit.planned_start_at AS "plannedStartAt",
|
||||
visit.operational_area_id AS "areaId",
|
||||
visit.operator_company_id AS "companyId",
|
||||
area.code AS "areaCode",
|
||||
area.name AS "areaName",
|
||||
company.code AS "companyCode",
|
||||
COALESCE(profile.legal_name, company.name) AS "companyName"
|
||||
FROM inspection_visits visit
|
||||
LEFT JOIN assets area ON area.id = visit.operational_area_id
|
||||
LEFT JOIN assets company ON company.id = visit.operator_company_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id = company.id
|
||||
WHERE visit.id = $1
|
||||
`, [visitId])) as Array<Record<string, unknown>>;
|
||||
|
||||
if (!visit) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_VISIT_NOT_FOUND',
|
||||
message: 'Inspección no encontrada',
|
||||
});
|
||||
}
|
||||
|
||||
// PostgreSQL/pg entrega los timestamptz como Date. String(date).slice(0, 10)
|
||||
// produce textos como "Thu Sep 17", que PostgreSQL rechaza al castear a date.
|
||||
const plannedOn = toIsoDate(visit.plannedStartAt);
|
||||
|
||||
const rows = (await this.dataSource.query(`
|
||||
WITH prior_acts AS (
|
||||
SELECT
|
||||
act.id AS act_id,
|
||||
act.code AS act_code,
|
||||
act.occurred_at,
|
||||
deadline.response_due_on,
|
||||
response.id AS latest_response_id,
|
||||
response.received_on AS response_received_on,
|
||||
response.committed_correction_on,
|
||||
finding.id AS finding_id,
|
||||
finding.code AS finding_code,
|
||||
finding.title AS finding_title,
|
||||
finding.status AS finding_status,
|
||||
finding.severity,
|
||||
finding.next_control_on,
|
||||
asset.id AS asset_id,
|
||||
asset.code AS asset_code,
|
||||
asset.name AS asset_name,
|
||||
asset_type.name AS asset_type_name,
|
||||
CASE
|
||||
WHEN response.id IS NULL
|
||||
AND deadline.response_due_on IS NOT NULL
|
||||
AND deadline.response_due_on < $4::date
|
||||
THEN 'ACT_RESPONSE_OVERDUE'
|
||||
WHEN response.id IS NULL
|
||||
AND deadline.response_due_on IS NOT NULL
|
||||
AND deadline.response_due_on BETWEEN $4::date AND ($4::date + 3)
|
||||
THEN 'ACT_RESPONSE_DUE_SOON'
|
||||
WHEN response.id IS NOT NULL
|
||||
AND response.committed_correction_on IS NOT NULL
|
||||
AND response.committed_correction_on < $4::date
|
||||
THEN 'COMPANY_COMMITMENT_OVERDUE'
|
||||
WHEN response.id IS NOT NULL
|
||||
AND finding.next_control_on IS NULL
|
||||
THEN 'VERIFICATION_PENDING'
|
||||
WHEN response.id IS NOT NULL
|
||||
THEN 'RESPONSE_RECEIVED'
|
||||
ELSE 'WAITING_RESPONSE'
|
||||
END AS admin_state
|
||||
FROM inspection_acts act
|
||||
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
|
||||
INNER JOIN inspection_findings finding ON finding.act_id = act.id
|
||||
AND finding.status = 'OPEN'
|
||||
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT event.response_due_on
|
||||
FROM inspection_act_deadline_events event
|
||||
WHERE event.act_id = act.id
|
||||
ORDER BY event.created_at DESC, event.id DESC
|
||||
LIMIT 1
|
||||
) deadline ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT company_response.id, company_response.received_on, company_response.committed_correction_on
|
||||
FROM inspection_act_company_responses company_response
|
||||
WHERE company_response.act_id = act.id
|
||||
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
|
||||
LIMIT 1
|
||||
) response ON true
|
||||
WHERE act.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
|
||||
AND source_visit.id <> $1
|
||||
AND source_visit.operational_area_id = $2::uuid
|
||||
AND source_visit.operator_company_id = $3::uuid
|
||||
)
|
||||
SELECT *
|
||||
FROM prior_acts
|
||||
WHERE admin_state IN (
|
||||
'ACT_RESPONSE_OVERDUE',
|
||||
'ACT_RESPONSE_DUE_SOON',
|
||||
'COMPANY_COMMITMENT_OVERDUE',
|
||||
'VERIFICATION_PENDING',
|
||||
'RESPONSE_RECEIVED'
|
||||
)
|
||||
OR next_control_on IS NOT NULL
|
||||
ORDER BY
|
||||
CASE admin_state
|
||||
WHEN 'ACT_RESPONSE_OVERDUE' THEN 1
|
||||
WHEN 'COMPANY_COMMITMENT_OVERDUE' THEN 2
|
||||
WHEN 'VERIFICATION_PENDING' THEN 3
|
||||
WHEN 'ACT_RESPONSE_DUE_SOON' THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
response_due_on NULLS LAST,
|
||||
occurred_at,
|
||||
act_code,
|
||||
finding_code
|
||||
`, [visitId, visit.areaId, visit.companyId, plannedOn])) as Array<{
|
||||
act_id: string;
|
||||
act_code: string;
|
||||
occurred_at: Date;
|
||||
response_due_on: string | null;
|
||||
latest_response_id: string | null;
|
||||
response_received_on: string | null;
|
||||
committed_correction_on: string | null;
|
||||
finding_id: string;
|
||||
finding_code: string;
|
||||
finding_title: string;
|
||||
finding_status: string;
|
||||
severity: number | null;
|
||||
next_control_on: string | null;
|
||||
asset_id: string;
|
||||
asset_code: string;
|
||||
asset_name: string;
|
||||
asset_type_name: string;
|
||||
admin_state: FieldBriefingActState;
|
||||
}>;
|
||||
|
||||
const byAct = new Map<string, FieldBriefingAct>();
|
||||
for (const row of rows) {
|
||||
let act = byAct.get(row.act_id);
|
||||
if (!act) {
|
||||
act = {
|
||||
actId: row.act_id,
|
||||
actCode: row.act_code,
|
||||
occurredAt: row.occurred_at,
|
||||
adminState: row.admin_state,
|
||||
responseDueOn: row.response_due_on,
|
||||
responseReceivedOn: row.response_received_on,
|
||||
committedCorrectionOn: row.committed_correction_on,
|
||||
latestResponseId: row.latest_response_id,
|
||||
findings: [],
|
||||
};
|
||||
byAct.set(row.act_id, act);
|
||||
}
|
||||
act.findings.push({
|
||||
id: row.finding_id,
|
||||
code: row.finding_code,
|
||||
title: row.finding_title,
|
||||
status: row.finding_status,
|
||||
severity: row.severity,
|
||||
nextControlOn: row.next_control_on,
|
||||
asset: {
|
||||
id: row.asset_id,
|
||||
code: row.asset_code,
|
||||
name: row.asset_name,
|
||||
typeName: row.asset_type_name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const acts = [...byAct.values()];
|
||||
const assetIds = new Set<string>();
|
||||
let findingCount = 0;
|
||||
for (const act of acts) {
|
||||
findingCount += act.findings.length;
|
||||
for (const finding of act.findings) assetIds.add(finding.asset.id);
|
||||
}
|
||||
|
||||
return {
|
||||
inspection: {
|
||||
id: visit.id,
|
||||
code: visit.code,
|
||||
status: visit.status,
|
||||
plannedStartAt: visit.plannedStartAt,
|
||||
area: visit.areaId ? { id: visit.areaId, code: visit.areaCode, name: visit.areaName } : null,
|
||||
operatorCompany: visit.companyId ? { id: visit.companyId, code: visit.companyCode, name: visit.companyName } : null,
|
||||
},
|
||||
plannedOn,
|
||||
summary: {
|
||||
acts: acts.length,
|
||||
findings: findingCount,
|
||||
inventoryItems: assetIds.size,
|
||||
responseOverdue: acts.filter((item) => item.adminState === 'ACT_RESPONSE_OVERDUE').length,
|
||||
commitmentOverdue: acts.filter((item) => item.adminState === 'COMPANY_COMMITMENT_OVERDUE').length,
|
||||
verificationPending: acts.filter((item) => item.adminState === 'VERIFICATION_PENDING').length,
|
||||
},
|
||||
acts,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PhaseADataModule } from '../core-data/phase-a-data.module';
|
||||
import { RolesController } from './roles/roles.controller';
|
||||
import { RolesService } from './roles/roles.service';
|
||||
import { UsersController } from './users/users.controller';
|
||||
import { UsersService } from './users/users.service';
|
||||
|
||||
@Module({
|
||||
imports: [PhaseADataModule, AuditModule, AuthModule],
|
||||
controllers: [UsersController, RolesController],
|
||||
providers: [UsersService, RolesService],
|
||||
})
|
||||
export class AdministrationModule {}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { isIP } from 'node:net';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { EntityManager } from 'typeorm';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../../common/http/request-context';
|
||||
import { AuditSource } from '../../database/entities';
|
||||
|
||||
const REQUIRED_RECOVERY_PERMISSIONS = [
|
||||
'roles.manage',
|
||||
'users.assign_roles',
|
||||
] as const;
|
||||
|
||||
export function administrationAuditContext(
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const candidateIp = request.ip || request.socket.remoteAddress || '';
|
||||
const rawUserAgent = request.header('user-agent')?.trim();
|
||||
|
||||
return {
|
||||
actorUserId: principal.userId,
|
||||
actorUsername: principal.username,
|
||||
requestId: request.requestId,
|
||||
source: AuditSource.WEB,
|
||||
ip: isIP(candidateIp) ? candidateIp : null,
|
||||
userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertAdministrativeRecoveryRemains(
|
||||
manager: EntityManager,
|
||||
): Promise<void> {
|
||||
const [row] = (await manager.query(
|
||||
`
|
||||
SELECT COUNT(*)::integer AS count
|
||||
FROM (
|
||||
SELECT user_account.id
|
||||
FROM users user_account
|
||||
INNER JOIN user_roles user_role
|
||||
ON user_role.user_id = user_account.id
|
||||
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_account.status = 'ACTIVE'
|
||||
AND permission.code = ANY($1::varchar[])
|
||||
GROUP BY user_account.id
|
||||
HAVING COUNT(DISTINCT permission.code) = $2
|
||||
) administrators
|
||||
`,
|
||||
[REQUIRED_RECOVERY_PERMISSIONS, REQUIRED_RECOVERY_PERMISSIONS.length],
|
||||
)) as Array<{ count: number }>;
|
||||
|
||||
if (!row || Number(row.count) < 1) {
|
||||
throw new ConflictException({
|
||||
code: 'LAST_ADMINISTRATOR_PROTECTED',
|
||||
message:
|
||||
'Debe permanecer al menos un usuario activo capaz de administrar roles y asignaciones',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: unknown }).code === '23505'
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateRoleDto {
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||
)
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(80)
|
||||
@Matches(/^[a-z][a-z0-9_-]+$/)
|
||||
code!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(1000)
|
||||
description!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsUUID('4', { each: true })
|
||||
permissionIds!: string[];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ArrayUnique, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReplaceRolePermissionsDto {
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsUUID('4', { each: true })
|
||||
permissionIds!: string[];
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UpdateRoleDto {
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../../common/http/request-context';
|
||||
import { CreateRoleDto } from './dto/create-role.dto';
|
||||
import { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto';
|
||||
import { UpdateRoleDto } from './dto/update-role.dto';
|
||||
import { RolesService } from './roles.service';
|
||||
|
||||
@Controller('roles')
|
||||
export class RolesController {
|
||||
constructor(private readonly roles: RolesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('roles.read')
|
||||
list() {
|
||||
return this.roles.list();
|
||||
}
|
||||
|
||||
@Get('permissions')
|
||||
@RequirePermissions('roles.read')
|
||||
listPermissions() {
|
||||
return this.roles.listPermissions();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('roles.manage')
|
||||
create(
|
||||
@Body() dto: CreateRoleDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.roles.create(dto, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('roles.read')
|
||||
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.roles.getById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('roles.manage')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpdateRoleDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.roles.update(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Put(':id/permissions')
|
||||
@RequirePermissions('roles.manage')
|
||||
replacePermissions(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ReplaceRolePermissionsDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.roles.replacePermissions(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { AuditService } from '../../audit/audit.service';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../../common/http/request-context';
|
||||
import {
|
||||
AuditAction,
|
||||
Permission,
|
||||
Role,
|
||||
RolePermission,
|
||||
} from '../../database/entities';
|
||||
import {
|
||||
administrationAuditContext,
|
||||
assertAdministrativeRecoveryRemains,
|
||||
isUniqueViolation,
|
||||
} from '../common/administration-audit';
|
||||
import type { CreateRoleDto } from './dto/create-role.dto';
|
||||
import type { ReplaceRolePermissionsDto } from './dto/replace-role-permissions.dto';
|
||||
import type { UpdateRoleDto } from './dto/update-role.dto';
|
||||
|
||||
export interface PermissionView {
|
||||
id: string;
|
||||
code: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface AdministrativeRoleView {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isSystem: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userCount: number;
|
||||
permissions: PermissionView[];
|
||||
}
|
||||
|
||||
function roleNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ROLE_NOT_FOUND',
|
||||
message: 'Rol no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function permissionSelectionInvalid(): BadRequestException {
|
||||
return new BadRequestException({
|
||||
code: 'PERMISSION_NOT_FOUND',
|
||||
message: 'Uno o más permisos no existen',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RolesService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async list(): Promise<{ data: AdministrativeRoleView[] }> {
|
||||
const rows = (await this.dataSource.query(this.roleViewQuery(''), [])) as
|
||||
AdministrativeRoleView[];
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
async listPermissions(): Promise<{ data: PermissionView[] }> {
|
||||
const rows = (await this.dataSource.query(
|
||||
`
|
||||
SELECT id, code, description
|
||||
FROM permissions
|
||||
ORDER BY code ASC
|
||||
`,
|
||||
)) as PermissionView[];
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AdministrativeRoleView> {
|
||||
return this.dataSource.transaction((manager) =>
|
||||
this.loadRoleView(manager, id),
|
||||
);
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateRoleDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AdministrativeRoleView> {
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
const permissions = await this.resolvePermissions(
|
||||
manager,
|
||||
dto.permissionIds,
|
||||
);
|
||||
const role = manager.getRepository(Role).create({
|
||||
code: dto.code.trim().toLowerCase(),
|
||||
name: dto.name.trim(),
|
||||
description: dto.description.trim(),
|
||||
isSystem: false,
|
||||
});
|
||||
await manager.getRepository(Role).save(role);
|
||||
await this.insertPermissions(manager, role.id, permissions);
|
||||
|
||||
const created = await this.loadRoleView(manager, role.id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ROLE_CREATED,
|
||||
entityType: 'role',
|
||||
entityId: role.id,
|
||||
afterData: { ...created },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw this.roleConflict();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateRoleDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AdministrativeRoleView> {
|
||||
if (dto.name === undefined && dto.description === undefined) {
|
||||
throw new BadRequestException({
|
||||
code: 'NO_CHANGES',
|
||||
message: 'No se recibieron cambios',
|
||||
});
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const role = await this.lockRole(manager, id);
|
||||
const before = await this.loadRoleView(manager, id);
|
||||
if (dto.name !== undefined) role.name = dto.name.trim();
|
||||
if (dto.description !== undefined) {
|
||||
role.description = dto.description.trim();
|
||||
}
|
||||
await manager.getRepository(Role).save(role);
|
||||
|
||||
const updated = await this.loadRoleView(manager, id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ROLE_UPDATED,
|
||||
entityType: 'role',
|
||||
entityId: id,
|
||||
beforeData: {
|
||||
name: before.name,
|
||||
description: before.description,
|
||||
},
|
||||
afterData: {
|
||||
name: updated.name,
|
||||
description: updated.description,
|
||||
},
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async replacePermissions(
|
||||
id: string,
|
||||
dto: ReplaceRolePermissionsDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AdministrativeRoleView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.lockRole(manager, id);
|
||||
const permissions = await this.resolvePermissions(
|
||||
manager,
|
||||
dto.permissionIds,
|
||||
);
|
||||
const before = await this.loadRoleView(manager, id);
|
||||
const beforeIds = before.permissions
|
||||
.map((permission) => permission.id)
|
||||
.sort();
|
||||
const afterIds = permissions.map((permission) => permission.id).sort();
|
||||
if (beforeIds.join(',') === afterIds.join(',')) return before;
|
||||
|
||||
await manager.getRepository(RolePermission).delete({ roleId: id });
|
||||
await this.insertPermissions(manager, id, permissions);
|
||||
await assertAdministrativeRecoveryRemains(manager);
|
||||
|
||||
const updated = await this.loadRoleView(manager, id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ROLE_PERMISSIONS_CHANGED,
|
||||
entityType: 'role',
|
||||
entityId: id,
|
||||
beforeData: { permissions: before.permissions },
|
||||
afterData: { permissions: updated.permissions },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
private async lockRole(manager: EntityManager, id: string): Promise<Role> {
|
||||
const role = await manager
|
||||
.getRepository(Role)
|
||||
.createQueryBuilder('role')
|
||||
.where('role.id = :id', { id })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!role) throw roleNotFound();
|
||||
return role;
|
||||
}
|
||||
|
||||
private async resolvePermissions(
|
||||
manager: EntityManager,
|
||||
permissionIds: string[],
|
||||
): Promise<Permission[]> {
|
||||
const uniqueIds = [...new Set(permissionIds)];
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const permissions = await manager.getRepository(Permission).find({
|
||||
where: { id: In(uniqueIds) },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
if (permissions.length !== uniqueIds.length) {
|
||||
throw permissionSelectionInvalid();
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
private async insertPermissions(
|
||||
manager: EntityManager,
|
||||
roleId: string,
|
||||
permissions: Permission[],
|
||||
): Promise<void> {
|
||||
if (permissions.length === 0) return;
|
||||
const assignments = permissions.map((permission) =>
|
||||
manager.getRepository(RolePermission).create({
|
||||
roleId,
|
||||
permissionId: permission.id,
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(RolePermission).save(assignments);
|
||||
}
|
||||
|
||||
private async loadRoleView(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
): Promise<AdministrativeRoleView> {
|
||||
const rows = (await manager.query(
|
||||
this.roleViewQuery('WHERE role.id = $1'),
|
||||
[id],
|
||||
)) as AdministrativeRoleView[];
|
||||
if (!rows[0]) throw roleNotFound();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private roleViewQuery(where: string): string {
|
||||
return `
|
||||
SELECT
|
||||
role.id,
|
||||
role.code,
|
||||
role.name,
|
||||
role.description,
|
||||
role.is_system AS "isSystem",
|
||||
role.created_at AS "createdAt",
|
||||
role.updated_at AS "updatedAt",
|
||||
(
|
||||
SELECT COUNT(*)::integer
|
||||
FROM user_roles user_role
|
||||
WHERE user_role.role_id = role.id
|
||||
) AS "userCount",
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSONB_AGG(
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', permission.id,
|
||||
'code', permission.code,
|
||||
'description', permission.description
|
||||
) ORDER BY permission.code
|
||||
)
|
||||
FROM role_permissions role_permission
|
||||
INNER JOIN permissions permission
|
||||
ON permission.id = role_permission.permission_id
|
||||
WHERE role_permission.role_id = role.id
|
||||
),
|
||||
'[]'::jsonb
|
||||
) AS permissions
|
||||
FROM roles role
|
||||
${where}
|
||||
ORDER BY role.is_system DESC, role.code ASC
|
||||
`;
|
||||
}
|
||||
|
||||
private roleConflict(): ConflictException {
|
||||
return new ConflictException({
|
||||
code: 'ROLE_ALREADY_EXISTS',
|
||||
message: 'Ya existe un rol con ese código',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { UserStatus } from '../../../database/entities';
|
||||
|
||||
export class ChangeUserStatusDto {
|
||||
@IsEnum(UserStatus)
|
||||
status!: UserStatus;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const optionalText = ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
||||
export class CreateUserDto {
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||
)
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(80)
|
||||
@Matches(/^[a-zA-Z0-9._-]+$/)
|
||||
username!: string;
|
||||
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
|
||||
)
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(320)
|
||||
email?: string | null;
|
||||
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
|
||||
)
|
||||
@IsOptional()
|
||||
@Matches(/^\d{7,11}$/)
|
||||
dni?: string | null;
|
||||
|
||||
@Transform(optionalText)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
phone?: string | null;
|
||||
|
||||
@Transform(optionalText)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
jobTitle?: string | null;
|
||||
|
||||
@Transform(optionalText)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
employeeNumber?: string | null;
|
||||
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
@MaxLength(128)
|
||||
password!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
firstName!: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
lastName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
mustChangePassword = true;
|
||||
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsUUID('4', { each: true })
|
||||
roleIds!: string[];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { UserStatus } from '../../../database/entities';
|
||||
|
||||
export class ListUsersQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize = 25;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UserStatus)
|
||||
status?: UserStatus;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ArrayUnique, IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReplaceUserRolesDto {
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsUUID('4', { each: true })
|
||||
roleIds!: string[];
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class ResetUserPasswordDto {
|
||||
@IsString()
|
||||
@MinLength(12)
|
||||
@MaxLength(128)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
mustChangePassword = true;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsEmail,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const optionalText = ({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
|
||||
export class UpdateUserDto {
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||
)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(80)
|
||||
@Matches(/^[a-zA-Z0-9._-]+$/)
|
||||
username?: string;
|
||||
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
|
||||
)
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(320)
|
||||
email?: string | null;
|
||||
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
|
||||
)
|
||||
@IsOptional()
|
||||
@Matches(/^\d{7,11}$/)
|
||||
dni?: string | null;
|
||||
|
||||
@Transform(optionalText)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
phone?: string | null;
|
||||
|
||||
@Transform(optionalText)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
jobTitle?: string | null;
|
||||
|
||||
@Transform(optionalText)
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
employeeNumber?: string | null;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
firstName?: string;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(120)
|
||||
lastName?: string;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../../common/http/request-context';
|
||||
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
|
||||
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('users.read')
|
||||
list(@Query() query: ListUsersQueryDto) {
|
||||
return this.users.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('users.create')
|
||||
create(
|
||||
@Body() dto: CreateUserDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.users.create(dto, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('users.read')
|
||||
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.users.getById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('users.update')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpdateUserDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.users.update(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePermissions('users.change_status')
|
||||
changeStatus(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ChangeUserStatusDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.users.changeStatus(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/reset-password')
|
||||
@RequirePermissions('users.update')
|
||||
resetPassword(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ResetUserPasswordDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.users.resetPassword(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Put(':id/roles')
|
||||
@RequirePermissions('users.assign_roles')
|
||||
replaceRoles(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ReplaceUserRolesDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.users.replaceRoles(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,543 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { AuditService } from '../../audit/audit.service';
|
||||
import { PasswordService } from '../../auth/services/password.service';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../../common/http/request-context';
|
||||
import { AuthSessionsRepository } from '../../core-data/repositories/auth-sessions.repository';
|
||||
import {
|
||||
AuditAction,
|
||||
Role,
|
||||
User,
|
||||
UserRole,
|
||||
UserStatus,
|
||||
} from '../../database/entities';
|
||||
import {
|
||||
administrationAuditContext,
|
||||
assertAdministrativeRecoveryRemains,
|
||||
isUniqueViolation,
|
||||
} from '../common/administration-audit';
|
||||
import type { ChangeUserStatusDto } from './dto/change-user-status.dto';
|
||||
import type { CreateUserDto } from './dto/create-user.dto';
|
||||
import type { ListUsersQueryDto } from './dto/list-users-query.dto';
|
||||
import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
|
||||
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
|
||||
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
export interface UserRoleView {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AdministrativeUserView {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
dni: string | null;
|
||||
phone: string | null;
|
||||
jobTitle: string | null;
|
||||
employeeNumber: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
status: UserStatus;
|
||||
mustChangePassword: boolean;
|
||||
failedLoginAttempts: number;
|
||||
lockedUntil: Date | null;
|
||||
lastLoginAt: Date | null;
|
||||
passwordChangedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
roles: UserRoleView[];
|
||||
}
|
||||
|
||||
interface UserViewRow extends AdministrativeUserView {
|
||||
total?: string | number;
|
||||
}
|
||||
|
||||
function userNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: 'Usuario no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function roleSelectionInvalid(): BadRequestException {
|
||||
return new BadRequestException({
|
||||
code: 'ROLE_NOT_FOUND',
|
||||
message: 'Uno o más roles no existen',
|
||||
});
|
||||
}
|
||||
|
||||
function inspectorEmailRequired(): BadRequestException {
|
||||
return new BadRequestException({
|
||||
code: 'INSPECTOR_EMAIL_REQUIRED',
|
||||
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly passwords: PasswordService,
|
||||
private readonly sessions: AuthSessionsRepository,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async list(query: ListUsersQueryDto) {
|
||||
const page = query.page;
|
||||
const pageSize = query.pageSize;
|
||||
const filters: string[] = [];
|
||||
const parameters: unknown[] = [];
|
||||
const search = query.search?.trim();
|
||||
|
||||
if (search) {
|
||||
parameters.push(`%${search}%`);
|
||||
filters.push(`
|
||||
(
|
||||
user_account.username ILIKE $${parameters.length}
|
||||
OR user_account.email ILIKE $${parameters.length}
|
||||
OR user_account.first_name ILIKE $${parameters.length}
|
||||
OR user_account.last_name ILIKE $${parameters.length}
|
||||
OR user_account.dni ILIKE $${parameters.length}
|
||||
OR user_account.phone ILIKE $${parameters.length}
|
||||
OR user_account.job_title ILIKE $${parameters.length}
|
||||
OR user_account.employee_number ILIKE $${parameters.length}
|
||||
)
|
||||
`);
|
||||
}
|
||||
if (query.status) {
|
||||
parameters.push(query.status);
|
||||
filters.push(`user_account.status = $${parameters.length}`);
|
||||
}
|
||||
|
||||
parameters.push(pageSize, (page - 1) * pageSize);
|
||||
const limitParameter = parameters.length - 1;
|
||||
const offsetParameter = parameters.length;
|
||||
const where = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
|
||||
const rows = (await this.dataSource.query(
|
||||
`
|
||||
SELECT
|
||||
user_account.id,
|
||||
user_account.username,
|
||||
user_account.email,
|
||||
user_account.dni,
|
||||
user_account.phone,
|
||||
user_account.job_title AS "jobTitle",
|
||||
user_account.employee_number AS "employeeNumber",
|
||||
user_account.first_name AS "firstName",
|
||||
user_account.last_name AS "lastName",
|
||||
user_account.status,
|
||||
user_account.must_change_password AS "mustChangePassword",
|
||||
user_account.failed_login_attempts AS "failedLoginAttempts",
|
||||
user_account.locked_until AS "lockedUntil",
|
||||
user_account.last_login_at AS "lastLoginAt",
|
||||
user_account.password_changed_at AS "passwordChangedAt",
|
||||
user_account.created_at AS "createdAt",
|
||||
user_account.updated_at AS "updatedAt",
|
||||
COALESCE(
|
||||
JSONB_AGG(
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', role.id,
|
||||
'code', role.code,
|
||||
'name', role.name
|
||||
) ORDER BY role.code
|
||||
) FILTER (WHERE role.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS roles,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM users user_account
|
||||
LEFT JOIN user_roles user_role
|
||||
ON user_role.user_id = user_account.id
|
||||
LEFT JOIN roles role ON role.id = user_role.role_id
|
||||
${where}
|
||||
GROUP BY user_account.id
|
||||
ORDER BY user_account.created_at DESC, user_account.username ASC
|
||||
LIMIT $${limitParameter} OFFSET $${offsetParameter}
|
||||
`,
|
||||
parameters,
|
||||
)) as UserViewRow[];
|
||||
|
||||
const total = rows.length > 0 ? Number(rows[0].total ?? 0) : 0;
|
||||
return {
|
||||
data: rows.map(({ total: _total, ...row }) => row),
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AdministrativeUserView> {
|
||||
return this.dataSource.transaction(async (manager) =>
|
||||
this.loadUserView(manager, id),
|
||||
);
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateUserDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AdministrativeUserView> {
|
||||
const passwordHash = await this.passwords.hash(dto.password);
|
||||
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
const roles = await this.resolveRoles(manager, dto.roleIds);
|
||||
this.assertInspectorHasEmail(roles, dto.email ?? null);
|
||||
const user = manager.getRepository(User).create({
|
||||
username: dto.username.trim().toLowerCase(),
|
||||
email: dto.email?.trim().toLowerCase() || null,
|
||||
dni: dto.dni ?? null,
|
||||
phone: dto.phone ?? null,
|
||||
jobTitle: dto.jobTitle ?? null,
|
||||
employeeNumber: dto.employeeNumber ?? null,
|
||||
passwordHash,
|
||||
firstName: dto.firstName.trim(),
|
||||
lastName: dto.lastName.trim(),
|
||||
status: UserStatus.ACTIVE,
|
||||
mustChangePassword: dto.mustChangePassword,
|
||||
failedLoginAttempts: 0,
|
||||
lockedUntil: null,
|
||||
lastLoginAt: null,
|
||||
passwordChangedAt: null,
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
});
|
||||
await manager.getRepository(User).save(user);
|
||||
await this.insertUserRoles(
|
||||
manager,
|
||||
user.id,
|
||||
roles,
|
||||
principal.userId,
|
||||
);
|
||||
|
||||
const created = await this.loadUserView(manager, user.id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.USER_CREATED,
|
||||
entityType: 'user',
|
||||
entityId: user.id,
|
||||
afterData: { ...created },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw this.userConflict();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateUserDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AdministrativeUserView> {
|
||||
if (
|
||||
dto.username === undefined &&
|
||||
dto.email === undefined &&
|
||||
dto.dni === undefined &&
|
||||
dto.phone === undefined &&
|
||||
dto.jobTitle === undefined &&
|
||||
dto.employeeNumber === undefined &&
|
||||
dto.firstName === undefined &&
|
||||
dto.lastName === undefined
|
||||
) {
|
||||
throw new BadRequestException({
|
||||
code: 'NO_CHANGES',
|
||||
message: 'No se recibieron cambios',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
const user = await this.lockUser(manager, id);
|
||||
const before = await this.loadUserView(manager, id);
|
||||
if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) {
|
||||
throw inspectorEmailRequired();
|
||||
}
|
||||
|
||||
if (dto.username !== undefined) {
|
||||
user.username = dto.username.trim().toLowerCase();
|
||||
}
|
||||
if (dto.email !== undefined) {
|
||||
user.email = dto.email?.trim().toLowerCase() || null;
|
||||
}
|
||||
if (dto.dni !== undefined) user.dni = dto.dni ?? null;
|
||||
if (dto.phone !== undefined) user.phone = dto.phone ?? null;
|
||||
if (dto.jobTitle !== undefined) user.jobTitle = dto.jobTitle ?? null;
|
||||
if (dto.employeeNumber !== undefined) user.employeeNumber = dto.employeeNumber ?? null;
|
||||
if (dto.firstName !== undefined) user.firstName = dto.firstName.trim();
|
||||
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
|
||||
user.updatedBy = principal.userId;
|
||||
await manager.getRepository(User).save(user);
|
||||
|
||||
const updated = await this.loadUserView(manager, id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.USER_UPDATED,
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
beforeData: { ...before },
|
||||
afterData: { ...updated },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw this.userConflict();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async changeStatus(
|
||||
id: string,
|
||||
dto: ChangeUserStatusDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AdministrativeUserView> {
|
||||
if (id === principal.userId && dto.status === UserStatus.INACTIVE) {
|
||||
throw new ConflictException({
|
||||
code: 'SELF_DEACTIVATION_FORBIDDEN',
|
||||
message: 'No puede desactivar su propio usuario',
|
||||
});
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const user = await this.lockUser(manager, id);
|
||||
const before = await this.loadUserView(manager, id);
|
||||
if (user.status === dto.status) return before;
|
||||
|
||||
user.status = dto.status;
|
||||
user.updatedBy = principal.userId;
|
||||
if (dto.status === UserStatus.ACTIVE) {
|
||||
user.failedLoginAttempts = 0;
|
||||
user.lockedUntil = null;
|
||||
}
|
||||
await manager.getRepository(User).save(user);
|
||||
|
||||
if (dto.status === UserStatus.INACTIVE) {
|
||||
await this.sessions.revokeUserSessions(id, undefined, manager);
|
||||
}
|
||||
await assertAdministrativeRecoveryRemains(manager);
|
||||
|
||||
const updated = await this.loadUserView(manager, id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.USER_STATUS_CHANGED,
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
beforeData: { status: before.status },
|
||||
afterData: { status: updated.status },
|
||||
metadata: {
|
||||
sessionsRevoked: dto.status === UserStatus.INACTIVE,
|
||||
},
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async resetPassword(
|
||||
id: string,
|
||||
dto: ResetUserPasswordDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AdministrativeUserView> {
|
||||
const passwordHash = await this.passwords.hash(dto.password);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const user = await this.lockUser(manager, id);
|
||||
const before = await this.loadUserView(manager, id);
|
||||
const now = new Date();
|
||||
|
||||
user.passwordHash = passwordHash;
|
||||
user.passwordChangedAt = now;
|
||||
user.mustChangePassword = dto.mustChangePassword;
|
||||
user.failedLoginAttempts = 0;
|
||||
user.lockedUntil = null;
|
||||
user.updatedBy = principal.userId;
|
||||
await manager.getRepository(User).save(user);
|
||||
await this.sessions.revokeUserSessions(id, undefined, manager);
|
||||
|
||||
const updated = await this.loadUserView(manager, id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.USER_PASSWORD_RESET,
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
beforeData: {
|
||||
mustChangePassword: before.mustChangePassword,
|
||||
failedLoginAttempts: before.failedLoginAttempts,
|
||||
lockedUntil: before.lockedUntil,
|
||||
},
|
||||
afterData: {
|
||||
mustChangePassword: updated.mustChangePassword,
|
||||
failedLoginAttempts: updated.failedLoginAttempts,
|
||||
lockedUntil: updated.lockedUntil,
|
||||
passwordChangedAt: updated.passwordChangedAt,
|
||||
},
|
||||
metadata: { sessionsRevoked: true, passwordValueRecorded: false },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async replaceRoles(
|
||||
id: string,
|
||||
dto: ReplaceUserRolesDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AdministrativeUserView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.lockUser(manager, id);
|
||||
const roles = await this.resolveRoles(manager, dto.roleIds);
|
||||
const before = await this.loadUserView(manager, id);
|
||||
this.assertInspectorHasEmail(roles, before.email);
|
||||
const beforeIds = before.roles.map((role) => role.id).sort();
|
||||
const afterIds = roles.map((role) => role.id).sort();
|
||||
if (beforeIds.join(',') === afterIds.join(',')) return before;
|
||||
|
||||
await manager.getRepository(UserRole).delete({ userId: id });
|
||||
await this.insertUserRoles(manager, id, roles, principal.userId);
|
||||
await assertAdministrativeRecoveryRemains(manager);
|
||||
|
||||
const updated = await this.loadUserView(manager, id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.USER_ROLES_CHANGED,
|
||||
entityType: 'user',
|
||||
entityId: id,
|
||||
beforeData: { roles: before.roles },
|
||||
afterData: { roles: updated.roles },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveRoles(
|
||||
manager: EntityManager,
|
||||
roleIds: string[],
|
||||
): Promise<Role[]> {
|
||||
const uniqueIds = [...new Set(roleIds)];
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const roles = await manager.getRepository(Role).find({
|
||||
where: { id: In(uniqueIds) },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
if (roles.length !== uniqueIds.length) throw roleSelectionInvalid();
|
||||
return roles;
|
||||
}
|
||||
|
||||
private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void {
|
||||
if (roles.some((role) => role.code === 'inspector') && !email?.trim()) {
|
||||
throw inspectorEmailRequired();
|
||||
}
|
||||
}
|
||||
|
||||
private async insertUserRoles(
|
||||
manager: EntityManager,
|
||||
userId: string,
|
||||
roles: Role[],
|
||||
assignedBy: string,
|
||||
): Promise<void> {
|
||||
if (roles.length === 0) return;
|
||||
const assignments = roles.map((role) =>
|
||||
manager.getRepository(UserRole).create({
|
||||
userId,
|
||||
roleId: role.id,
|
||||
assignedBy,
|
||||
}),
|
||||
);
|
||||
await manager.getRepository(UserRole).save(assignments);
|
||||
}
|
||||
|
||||
private async lockUser(manager: EntityManager, id: string): Promise<User> {
|
||||
const user = await manager
|
||||
.getRepository(User)
|
||||
.createQueryBuilder('user')
|
||||
.where('user.id = :id', { id })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!user) throw userNotFound();
|
||||
return user;
|
||||
}
|
||||
|
||||
private async loadUserView(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
): Promise<AdministrativeUserView> {
|
||||
const [row] = (await manager.query(
|
||||
`
|
||||
SELECT
|
||||
user_account.id,
|
||||
user_account.username,
|
||||
user_account.email,
|
||||
user_account.dni,
|
||||
user_account.phone,
|
||||
user_account.job_title AS "jobTitle",
|
||||
user_account.employee_number AS "employeeNumber",
|
||||
user_account.first_name AS "firstName",
|
||||
user_account.last_name AS "lastName",
|
||||
user_account.status,
|
||||
user_account.must_change_password AS "mustChangePassword",
|
||||
user_account.failed_login_attempts AS "failedLoginAttempts",
|
||||
user_account.locked_until AS "lockedUntil",
|
||||
user_account.last_login_at AS "lastLoginAt",
|
||||
user_account.password_changed_at AS "passwordChangedAt",
|
||||
user_account.created_at AS "createdAt",
|
||||
user_account.updated_at AS "updatedAt",
|
||||
COALESCE(
|
||||
JSONB_AGG(
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', role.id,
|
||||
'code', role.code,
|
||||
'name', role.name
|
||||
) ORDER BY role.code
|
||||
) FILTER (WHERE role.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS roles
|
||||
FROM users user_account
|
||||
LEFT JOIN user_roles user_role
|
||||
ON user_role.user_id = user_account.id
|
||||
LEFT JOIN roles role ON role.id = user_role.role_id
|
||||
WHERE user_account.id = $1
|
||||
GROUP BY user_account.id
|
||||
`,
|
||||
[id],
|
||||
)) as AdministrativeUserView[];
|
||||
if (!row) throw userNotFound();
|
||||
return row;
|
||||
}
|
||||
|
||||
private userConflict(): ConflictException {
|
||||
return new ConflictException({
|
||||
code: 'USER_ALREADY_EXISTS',
|
||||
message: 'El usuario, email o DNI ya está registrado',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AdministrationModule } from './administration/administration.module';
|
||||
import { AuthorizationModule } from './authorization/authorization.module';
|
||||
import { PermissionsGuard } from './authorization/guards/permissions.guard';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AccessTokenGuard } from './auth/guards/access-token.guard';
|
||||
import { CsrfGuard } from './auth/guards/csrf.guard';
|
||||
import { PhaseADataModule } from './core-data/phase-a-data.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { AssetMasterModule } from './asset-master/asset-master.module';
|
||||
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
|
||||
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
|
||||
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
||||
import { InspectionClosingModule } from './inspection-closing/inspection-closing.module';
|
||||
import { AssetImportsModule } from './asset-imports/asset-imports.module';
|
||||
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
|
||||
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
|
||||
import { ActAdministrationModule } from './act-administration/act-administration.module';
|
||||
|
||||
function required(config: ConfigService, key: string): string {
|
||||
const value = config.get<string>(key);
|
||||
if (!value) throw new Error(`Missing required environment variable: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
ignoreEnvFile: true,
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: required(config, 'DB_HOST'),
|
||||
port: Number(config.get<string>('DB_PORT') ?? 5432),
|
||||
database: required(config, 'DB_NAME'),
|
||||
username: required(config, 'DB_APP_USER'),
|
||||
password: required(config, 'DB_APP_PASSWORD'),
|
||||
autoLoadEntities: true,
|
||||
synchronize: false,
|
||||
migrationsRun: false,
|
||||
logging: false,
|
||||
applicationName: 'dhv2-api',
|
||||
connectTimeoutMS: 5000,
|
||||
}),
|
||||
}),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
name: 'default',
|
||||
ttl: 60_000,
|
||||
limit: 120,
|
||||
},
|
||||
]),
|
||||
PhaseADataModule,
|
||||
AuditModule,
|
||||
AuthorizationModule,
|
||||
AuthModule,
|
||||
AdministrationModule,
|
||||
DashboardModule,
|
||||
AssetMasterModule,
|
||||
InspectionVisitsModule,
|
||||
InspectionActsModule,
|
||||
InspectionFindingsModule,
|
||||
InspectionClosingModule,
|
||||
InspectionReportsModule,
|
||||
InspectionVerificationsModule,
|
||||
ActAdministrationModule,
|
||||
AssetImportsModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: AccessTokenGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
{ provide: APP_GUARD, useClass: CsrfGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { execFile as execFileCallback } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
export const MAX_ASSET_IMPORT_BYTES = 25 * 1024 * 1024;
|
||||
const MAX_XLSX_UNCOMPRESSED_BYTES = 120 * 1024 * 1024;
|
||||
const MAX_XLSX_ENTRIES = 2500;
|
||||
const MAX_ROWS_PER_SHEET = 50_000;
|
||||
const MAX_COLUMNS = 120;
|
||||
|
||||
export interface UploadedImportFile {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface ParsedSheet {
|
||||
name: string;
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export interface ParsedWorkbook {
|
||||
kind: 'XLSX' | 'CSV';
|
||||
sheets: ParsedSheet[];
|
||||
}
|
||||
|
||||
function importFileError(code: string, message: string): BadRequestException {
|
||||
return new BadRequestException({ code, message });
|
||||
}
|
||||
|
||||
export function inspectImportFile(file: UploadedImportFile | undefined): { extension: '.xlsx' | '.csv'; mimeType: string } {
|
||||
if (!file?.buffer?.length) throw importFileError('IMPORT_FILE_REQUIRED', 'Seleccioná un archivo XLSX o CSV');
|
||||
if (file.buffer.length > MAX_ASSET_IMPORT_BYTES) throw importFileError('IMPORT_FILE_TOO_LARGE', 'El archivo supera el límite de 25 MB');
|
||||
const lower = file.originalname.toLowerCase();
|
||||
if (lower.endsWith('.xlsx')) {
|
||||
if (!(file.buffer[0] === 0x50 && file.buffer[1] === 0x4b)) {
|
||||
throw importFileError('INVALID_XLSX_FILE', 'El contenido no corresponde a un archivo XLSX válido');
|
||||
}
|
||||
return { extension: '.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' };
|
||||
}
|
||||
if (lower.endsWith('.csv')) {
|
||||
if (file.buffer.includes(0)) throw importFileError('INVALID_CSV_FILE', 'El CSV contiene datos binarios no admitidos');
|
||||
return { extension: '.csv', mimeType: 'text/csv' };
|
||||
}
|
||||
throw importFileError('UNSUPPORTED_IMPORT_FILE', 'Sólo se admiten archivos .xlsx y .csv');
|
||||
}
|
||||
|
||||
function decodeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
||||
.replace(/&#([0-9]+);/g, (_match, decimal: string) => String.fromCodePoint(Number.parseInt(decimal, 10)))
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
function stripXmlText(xml: string): string {
|
||||
const parts: string[] = [];
|
||||
for (const match of xml.matchAll(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g)) parts.push(decodeXml(match[1] ?? ''));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function columnIndex(reference: string): number {
|
||||
const match = /^([A-Z]+)\d+$/i.exec(reference);
|
||||
if (!match) return -1;
|
||||
let result = 0;
|
||||
for (const char of match[1]!.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64);
|
||||
return result - 1;
|
||||
}
|
||||
|
||||
async function zipList(filePath: string): Promise<string[]> {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await execFile('unzip', ['-Z1', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }));
|
||||
} catch {
|
||||
throw importFileError('XLSX_UNZIP_UNAVAILABLE', 'No se pudo inspeccionar el XLSX. Verificá que el archivo no esté dañado');
|
||||
}
|
||||
const entries = stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean);
|
||||
if (entries.length > MAX_XLSX_ENTRIES) throw importFileError('XLSX_TOO_COMPLEX', 'El XLSX contiene demasiados archivos internos');
|
||||
if (entries.some((entry) => entry.startsWith('/') || entry.split('/').includes('..'))) {
|
||||
throw importFileError('INVALID_XLSX_PATH', 'El XLSX contiene rutas internas inválidas');
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function assertZipSize(filePath: string): Promise<void> {
|
||||
try {
|
||||
const { stdout } = await execFile('unzip', ['-l', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
|
||||
const summary = stdout.split(/\r?\n/).reverse().find((line) => /\bfiles?\b/.test(line));
|
||||
const bytes = summary ? Number(/^\s*(\d+)/.exec(summary)?.[1] ?? 0) : 0;
|
||||
if (Number.isFinite(bytes) && bytes > MAX_XLSX_UNCOMPRESSED_BYTES) {
|
||||
throw importFileError('XLSX_UNCOMPRESSED_TOO_LARGE', 'El contenido descomprimido del XLSX supera el límite de seguridad');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) throw error;
|
||||
throw importFileError('INVALID_XLSX_FILE', 'No se pudo leer la estructura interna del XLSX');
|
||||
}
|
||||
}
|
||||
|
||||
async function zipEntry(filePath: string, entry: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFile('unzip', ['-p', filePath, entry], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: MAX_XLSX_UNCOMPRESSED_BYTES,
|
||||
});
|
||||
return stdout;
|
||||
} catch {
|
||||
throw importFileError('INVALID_XLSX_FILE', `No se pudo leer ${entry} dentro del XLSX`);
|
||||
}
|
||||
}
|
||||
|
||||
function workbookSheets(workbookXml: string, relationshipsXml: string): Array<{ name: string; path: string }> {
|
||||
const relationTargets = new Map<string, string>();
|
||||
for (const relation of relationshipsXml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
|
||||
const attributes = relation[1] ?? '';
|
||||
const id = /\bId="([^"]+)"/.exec(attributes)?.[1];
|
||||
const target = /\bTarget="([^"]+)"/.exec(attributes)?.[1];
|
||||
if (id && target) relationTargets.set(id, target);
|
||||
}
|
||||
const result: Array<{ name: string; path: string }> = [];
|
||||
for (const sheet of workbookXml.matchAll(/<sheet\b([^>]*)\/?\s*>/g)) {
|
||||
const attributes = sheet[1] ?? '';
|
||||
const name = decodeXml(/\bname="([^"]+)"/.exec(attributes)?.[1] ?? 'Hoja');
|
||||
const relationId = /\br:id="([^"]+)"/.exec(attributes)?.[1];
|
||||
if (!relationId) continue;
|
||||
const target = relationTargets.get(relationId);
|
||||
if (!target) continue;
|
||||
const clean = target.replace(/^\//, '');
|
||||
const path = clean.startsWith('xl/') ? clean : `xl/${clean.replace(/^\.\//, '')}`;
|
||||
result.push({ name, path });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSharedStrings(xml: string): string[] {
|
||||
const result: string[] = [];
|
||||
for (const match of xml.matchAll(/<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g)) result.push(stripXmlText(match[1] ?? ''));
|
||||
return result;
|
||||
}
|
||||
|
||||
function cellValue(cellXml: string, cellType: string | undefined, sharedStrings: string[]): string {
|
||||
if (cellType === 'inlineStr') return stripXmlText(cellXml).trim();
|
||||
const raw = /<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(cellXml)?.[1] ?? '';
|
||||
const value = decodeXml(raw);
|
||||
if (cellType === 's') {
|
||||
const index = Number.parseInt(value, 10);
|
||||
return Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';
|
||||
}
|
||||
if (cellType === 'b') return value === '1' ? 'TRUE' : 'FALSE';
|
||||
if (cellType === 'str') return value;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSheetXml(xml: string, sharedStrings: string[]): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let count = 0;
|
||||
for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
|
||||
if (++count > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `La hoja supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
|
||||
const values: string[] = [];
|
||||
const rowXml = rowMatch[1] ?? '';
|
||||
for (const cellMatch of rowXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||
const attributes = cellMatch[1] ?? '';
|
||||
const reference = /\br="([A-Z]+\d+)"/i.exec(attributes)?.[1];
|
||||
if (!reference) continue;
|
||||
const index = columnIndex(reference);
|
||||
if (index < 0 || index >= MAX_COLUMNS) continue;
|
||||
const type = /\bt="([^"]+)"/.exec(attributes)?.[1];
|
||||
values[index] = cellValue(cellMatch[2] ?? '', type, sharedStrings).trim();
|
||||
}
|
||||
while (values.length && !values[values.length - 1]) values.pop();
|
||||
rows.push(values.map((value) => value ?? ''));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function detectDelimiter(firstLines: string[]): ',' | ';' | '\t' {
|
||||
const candidates: Array<',' | ';' | '\t'> = [',', ';', '\t'];
|
||||
let best: ',' | ';' | '\t' = ',';
|
||||
let score = -1;
|
||||
for (const candidate of candidates) {
|
||||
const current = firstLines.reduce((sum, line) => sum + line.split(candidate).length - 1, 0);
|
||||
if (current > score) { score = current; best = candidate; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function parseCsv(text: string): string[][] {
|
||||
const normalized = text.replace(/^\uFEFF/, '');
|
||||
const sample = normalized.split(/\r?\n/).slice(0, 8);
|
||||
const delimiter = detectDelimiter(sample);
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let value = '';
|
||||
let quoted = false;
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
const char = normalized[i]!;
|
||||
if (char === '"') {
|
||||
if (quoted && normalized[i + 1] === '"') { value += '"'; i += 1; }
|
||||
else quoted = !quoted;
|
||||
continue;
|
||||
}
|
||||
if (!quoted && char === delimiter) { row.push(value.trim()); value = ''; continue; }
|
||||
if (!quoted && (char === '\n' || char === '\r')) {
|
||||
if (char === '\r' && normalized[i + 1] === '\n') i += 1;
|
||||
row.push(value.trim()); value = '';
|
||||
if (row.some(Boolean)) rows.push(row);
|
||||
row = [];
|
||||
if (rows.length > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `El archivo supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`);
|
||||
continue;
|
||||
}
|
||||
value += char;
|
||||
}
|
||||
if (value.length || row.length) { row.push(value.trim()); if (row.some(Boolean)) rows.push(row); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function parseImportWorkbook(filePath: string, extension: '.xlsx' | '.csv'): Promise<ParsedWorkbook> {
|
||||
if (extension === '.csv') {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const text = await readFile(filePath, 'utf8');
|
||||
return { kind: 'CSV', sheets: [{ name: 'CSV', rows: parseCsv(text) }] };
|
||||
}
|
||||
await assertZipSize(filePath);
|
||||
const entries = await zipList(filePath);
|
||||
if (!entries.includes('xl/workbook.xml') || !entries.includes('xl/_rels/workbook.xml.rels')) {
|
||||
throw importFileError('INVALID_XLSX_FILE', 'El archivo no contiene una estructura XLSX compatible');
|
||||
}
|
||||
const [workbookXml, relationshipsXml] = await Promise.all([
|
||||
zipEntry(filePath, 'xl/workbook.xml'),
|
||||
zipEntry(filePath, 'xl/_rels/workbook.xml.rels'),
|
||||
]);
|
||||
const sharedStrings = entries.includes('xl/sharedStrings.xml')
|
||||
? parseSharedStrings(await zipEntry(filePath, 'xl/sharedStrings.xml'))
|
||||
: [];
|
||||
const sheets = workbookSheets(workbookXml, relationshipsXml);
|
||||
if (!sheets.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'El XLSX no contiene hojas legibles');
|
||||
const parsed: ParsedSheet[] = [];
|
||||
for (const sheet of sheets.slice(0, 30)) {
|
||||
if (!entries.includes(sheet.path)) continue;
|
||||
const xml = await zipEntry(filePath, sheet.path);
|
||||
parsed.push({ name: sheet.name, rows: parseSheetXml(xml, sharedStrings) });
|
||||
}
|
||||
if (!parsed.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'No se pudo leer ninguna hoja del XLSX');
|
||||
return { kind: 'XLSX', sheets: parsed };
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export type AssetImportPlanEntityKind = 'DEPARTMENT' | 'ORGANIZATION' | 'AREA' | 'AREA_DEPARTMENT_RELATION' | 'FIELD' | 'OPERATOR_RELATION' | 'LEGAL_RIGHT' | 'LEGAL_RIGHT_ORGANIZATION' | 'INSTALLATION' | 'LOCAL_STRUCTURE' | 'TECHNICAL_ASSET';
|
||||
export type AssetImportPlanAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
|
||||
export type AssetImportPlanItemStatus = 'PLANNED' | 'MATCHED' | 'REVIEW' | 'IGNORED' | 'APPLIED' | 'ROLLED_BACK' | 'FAILED';
|
||||
export type AssetImportPlanStatus = 'REVIEW_REQUIRED' | 'READY' | 'APPLIED' | 'ROLLED_BACK' | 'SUPERSEDED' | 'FAILED';
|
||||
|
||||
export interface AssetImportPlanDraftItem {
|
||||
entityKey: string;
|
||||
entityKind: AssetImportPlanEntityKind;
|
||||
action: AssetImportPlanAction;
|
||||
status: AssetImportPlanItemStatus;
|
||||
assetTypeCode: string | null;
|
||||
displayName: string;
|
||||
generatedCode: string | null;
|
||||
parentEntityKey: string | null;
|
||||
matchedAssetId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
sourceRowNumbers: number[];
|
||||
reviewCodes: string[];
|
||||
}
|
||||
|
||||
export function plainImportKey(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function organizationImportKey(value: unknown): string {
|
||||
const tokens = plainImportKey(value).split(' ').filter(Boolean);
|
||||
const output: string[] = [];
|
||||
for (let index = 0; index < tokens.length;) {
|
||||
if (tokens[index]!.length !== 1) { output.push(tokens[index]!); index += 1; continue; }
|
||||
const letters: string[] = [];
|
||||
let cursor = index;
|
||||
while (cursor < tokens.length && tokens[cursor]!.length === 1) { letters.push(tokens[cursor]!); cursor += 1; }
|
||||
output.push(letters.length >= 2 ? letters.join('') : letters[0]!);
|
||||
index = cursor;
|
||||
}
|
||||
return output.join(' ');
|
||||
}
|
||||
|
||||
|
||||
export function isExplicitlyUnassignedOperator(value: unknown): boolean {
|
||||
return plainImportKey(value) === 'sin empresa operadora';
|
||||
}
|
||||
|
||||
|
||||
export type NormalizedLegalRightType = 'EXPLOITATION_CONCESSION' | 'EXPLORATION_PERMIT' | 'TRANSPORT_CONCESSION' | 'OTHER';
|
||||
|
||||
export function normalizedLegalRightType(value: unknown): NormalizedLegalRightType | null {
|
||||
const normalized = plainImportKey(value);
|
||||
if (normalized === 'explotacion') return 'EXPLOITATION_CONCESSION';
|
||||
if (normalized === 'exploracion') return 'EXPLORATION_PERMIT';
|
||||
if (normalized === 'transporte' || normalized === 'concesion de transporte') return 'TRANSPORT_CONCESSION';
|
||||
if (!normalized) return null;
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
export function legalRightTypeLabel(value: NormalizedLegalRightType): string {
|
||||
if (value === 'EXPLOITATION_CONCESSION') return 'Concesión de explotación';
|
||||
if (value === 'EXPLORATION_PERMIT') return 'Permiso de exploración';
|
||||
if (value === 'TRANSPORT_CONCESSION') return 'Concesión de transporte';
|
||||
return 'Otro derecho';
|
||||
}
|
||||
|
||||
export function departmentCode(value: unknown): string {
|
||||
const normalized = String(value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase().replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70);
|
||||
return normalized || 'SIN-DEPARTAMENTO';
|
||||
}
|
||||
|
||||
export function externalIdNamespace(input: string | null | undefined, originalName: string): string {
|
||||
const seed = (input?.trim() || originalName.replace(/\.[^.]+$/, '').split(/[-_]/)[0] || 'IMPORT').normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
const normalized = seed.toUpperCase().replace(/[^A-Z0-9._/-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
||||
if (normalized.length >= 2 && /^[A-Z0-9]/.test(normalized)) return normalized;
|
||||
return 'IMPORT';
|
||||
}
|
||||
|
||||
export function generatedImportCode(planId: string, typeCode: string, entityKey: string): string {
|
||||
const prefix = typeCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 10) || 'ACT';
|
||||
const digest = createHash('sha256').update(`${planId}\u001f${entityKey}`).digest('hex').slice(0, 12).toUpperCase();
|
||||
return `IMP-${prefix}-${digest}`;
|
||||
}
|
||||
|
||||
export function technicalFamilyTypeCode(family: unknown, subtype: unknown): string {
|
||||
const normalizedFamily = plainImportKey(family).replace(/ /g, '_');
|
||||
const normalizedSubtype = plainImportKey(subtype).replace(/ /g, '_');
|
||||
const direct = new Set(['tanque','separador','bomba','caldera','antorcha','colector','filtro','calentador','ducto','pozo']);
|
||||
if (direct.has(normalizedFamily)) return normalizedFamily;
|
||||
if (normalizedFamily === 'pileta') return 'pileta_api';
|
||||
if (normalizedFamily === 'defensa_incendios') return 'sistema_defensa_incendios';
|
||||
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'planta') return 'planta';
|
||||
if (normalizedFamily === 'instalacion' && normalizedSubtype === 'bateria') return 'bateria';
|
||||
return 'equipo';
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface SourceLocalStructureSuggestion {
|
||||
displayName: string;
|
||||
sourcePath: string[];
|
||||
sourceInstallation: string | null;
|
||||
sourceSubInstallation: string | null;
|
||||
concreteFromLocation: boolean;
|
||||
sourceGroupOnly: boolean;
|
||||
}
|
||||
|
||||
export function sourceLocalStructureSuggestion(
|
||||
areaOrField: unknown,
|
||||
installation: unknown,
|
||||
subInstallation: unknown,
|
||||
location: unknown,
|
||||
): SourceLocalStructureSuggestion | null {
|
||||
const areaText = String(areaOrField ?? '').trim();
|
||||
const installationText = String(installation ?? '').trim();
|
||||
const subInstallationText = String(subInstallation ?? '').trim();
|
||||
const locationText = String(location ?? '').trim();
|
||||
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
|
||||
const sourcePath = locationText.split('/').map((segment) => segment.trim()).filter((segment) => !unusable(segment));
|
||||
const areaKey = plainImportKey(areaText);
|
||||
const installationKey = plainImportKey(installationText);
|
||||
const subInstallationKey = plainImportKey(subInstallationText);
|
||||
const genericProvinceKeys = new Set(['mendoza', 'provincia de mendoza']);
|
||||
const genericLocationKeys = new Set([
|
||||
'yacimiento','planta','energia','edilicio','transporte','repositorio','repositorios','op digitales',
|
||||
'bateria','set','pta','ptc','em','pcg','et','oficina','estacion de servicio','taller','almacen',
|
||||
]);
|
||||
|
||||
const contextualSegments = sourcePath.filter((segment, index) => {
|
||||
const key = plainImportKey(segment);
|
||||
if (!key) return false;
|
||||
if (index === 0 && genericProvinceKeys.has(key)) return false;
|
||||
if (areaKey && key === areaKey) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const concrete = [...contextualSegments].reverse().find((segment) => {
|
||||
const key = plainImportKey(segment);
|
||||
if (!key) return false;
|
||||
if (installationKey && key === installationKey) return false;
|
||||
if (subInstallationKey && key === subInstallationKey) return false;
|
||||
if (/^pozo\b/.test(key)) return false;
|
||||
return !genericLocationKeys.has(key);
|
||||
}) ?? null;
|
||||
|
||||
if (concrete) {
|
||||
return {
|
||||
displayName: concrete.slice(0, 200),
|
||||
sourcePath,
|
||||
sourceInstallation: unusable(installationText) ? null : installationText,
|
||||
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
|
||||
concreteFromLocation: true,
|
||||
sourceGroupOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
const categoryParts = [installationText, subInstallationText]
|
||||
.filter((value) => !unusable(value))
|
||||
.filter((value, index, values) => values.findIndex((candidate) => plainImportKey(candidate) === plainImportKey(value)) === index);
|
||||
if (!categoryParts.length) return null;
|
||||
return {
|
||||
displayName: categoryParts.join(' / ').slice(0, 200),
|
||||
sourcePath,
|
||||
sourceInstallation: unusable(installationText) ? null : installationText,
|
||||
sourceSubInstallation: unusable(subInstallationText) ? null : subInstallationText,
|
||||
concreteFromLocation: false,
|
||||
sourceGroupOnly: true,
|
||||
};
|
||||
}
|
||||
export function sourceContainerTypeCode(installation: unknown, subInstallation?: unknown): string {
|
||||
const parent = plainImportKey(installation);
|
||||
const sub = plainImportKey(subInstallation);
|
||||
if (sub === 'bateria') return 'bateria';
|
||||
if (['pta', 'ptc', 'pcg'].includes(sub)) return 'planta';
|
||||
if (sub === 'estacion de servicio') return 'estacion';
|
||||
if (/\bplanta\b/.test(parent)) return 'planta';
|
||||
if (/\bbateria\b/.test(parent)) return 'bateria';
|
||||
if (/\bsubestacion\b/.test(parent)) return 'subestacion';
|
||||
if (/\bestacion\b/.test(parent)) return 'estacion';
|
||||
if (/\blocacion\b/.test(parent)) return 'locacion';
|
||||
return 'instalacion';
|
||||
}
|
||||
|
||||
export function sourceContainerName(installation: unknown, subInstallation: unknown, location?: unknown): string | null {
|
||||
const sub = String(subInstallation ?? '').trim();
|
||||
const parent = String(installation ?? '').trim();
|
||||
const locationText = String(location ?? '').trim();
|
||||
const unusable = (value: string) => !value || ['-', 'n/a', 'na', 's/d', 'sd', 'sin dato', 'sin datos'].includes(plainImportKey(value));
|
||||
const genericSub = new Set(['bateria','set','pta','ptc','em','pcg','et','transporte','oficina','repositorio','repositorios','estacion de servicio','taller','op digitales','almacen']);
|
||||
const subKey = plainImportKey(sub);
|
||||
if (!unusable(sub) && !genericSub.has(subKey)) return sub;
|
||||
|
||||
const segments = locationText.split('/').map((segment) => segment.trim()).filter(Boolean);
|
||||
const prefixes: Record<string, RegExp> = {
|
||||
bateria: /^BAT[A-Z0-9-]/i,
|
||||
pta: /^PTA[A-Z0-9-]/i,
|
||||
ptc: /^(PTC[A-Z0-9-]|ESTACION DE BOMBEO)/i,
|
||||
pcg: /^PCG[A-Z0-9-]/i,
|
||||
};
|
||||
const prefix = prefixes[subKey];
|
||||
if (prefix) {
|
||||
const candidate = segments.find((segment) => plainImportKey(segment) !== subKey && prefix.test(segment));
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
|
||||
if (!unusable(parent) && !['planta','bateria','estacion','subestacion','yacimiento','locacion','energia','edilicio'].includes(plainImportKey(parent))) return parent;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function planItemHash(items: Array<Pick<AssetImportPlanDraftItem, 'entityKey' | 'entityKind' | 'action' | 'status' | 'assetTypeCode' | 'displayName' | 'generatedCode' | 'parentEntityKey' | 'matchedAssetId' | 'payload' | 'sourceRowNumbers' | 'reviewCodes'>>): string {
|
||||
const canonical = items
|
||||
.map((item) => ({
|
||||
entityKey: item.entityKey,
|
||||
entityKind: item.entityKind,
|
||||
action: item.action,
|
||||
status: item.status,
|
||||
assetTypeCode: item.assetTypeCode,
|
||||
displayName: item.displayName,
|
||||
generatedCode: item.generatedCode,
|
||||
parentEntityKey: item.parentEntityKey,
|
||||
matchedAssetId: item.matchedAssetId,
|
||||
payload: item.payload,
|
||||
sourceRowNumbers: [...item.sourceRowNumbers].sort((a, b) => a - b),
|
||||
reviewCodes: [...item.reviewCodes].sort(),
|
||||
}))
|
||||
.sort((a, b) => a.entityKey.localeCompare(b.entityKey));
|
||||
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
||||
}
|
||||
|
||||
export const PLAN_DEPENDENCY_REVIEW_CODES = new Set([
|
||||
'PLAN_DEPARTMENT_REVIEW_REQUIRED',
|
||||
'PLAN_LEGAL_RIGHT_REVIEW_REQUIRED',
|
||||
'PLAN_AREA_REVIEW_REQUIRED',
|
||||
'PLAN_ORGANIZATION_REVIEW_REQUIRED',
|
||||
'PLAN_TERRITORY_CONTEXT_REQUIRED',
|
||||
'PLAN_CONTEXT_DECISION_REQUIRED',
|
||||
'PLAN_CONTAINER_REVIEW_REQUIRED',
|
||||
'PLAN_PARENT_NOT_RESOLVED',
|
||||
]);
|
||||
|
||||
export function isPlanDependencyReview(item: Pick<AssetImportPlanDraftItem, 'action' | 'reviewCodes'>): boolean {
|
||||
return item.action === 'REVIEW'
|
||||
&& item.reviewCodes.length > 0
|
||||
&& item.reviewCodes.every((code) => PLAN_DEPENDENCY_REVIEW_CODES.has(code));
|
||||
}
|
||||
|
||||
export function planDependencyKeys(item: Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload'>): string[] {
|
||||
const keys = new Set<string>();
|
||||
if (item.parentEntityKey) keys.add(item.parentEntityKey);
|
||||
for (const key of ['areaEntityKey','organizationEntityKey','departmentEntityKey','legalRightEntityKey','operationalAreaEntityKey','operatorEntityKey','localStructureEntityKey']) {
|
||||
const value = item.payload[key];
|
||||
if (typeof value === 'string' && value.trim()) keys.add(value.trim());
|
||||
}
|
||||
keys.delete(item.entityKey);
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function safePlanItems<T extends Pick<AssetImportPlanDraftItem, 'entityKey' | 'parentEntityKey' | 'payload' | 'action' | 'status'>>(items: T[]): T[] {
|
||||
const byKey = new Map(items.map((item) => [item.entityKey, item]));
|
||||
const memo = new Map<string, boolean>();
|
||||
const visiting = new Set<string>();
|
||||
const isSafe = (item: T): boolean => {
|
||||
if (item.status === 'APPLIED') return true;
|
||||
if (item.action === 'REVIEW') return false;
|
||||
const cached = memo.get(item.entityKey);
|
||||
if (cached !== undefined) return cached;
|
||||
if (visiting.has(item.entityKey)) return false;
|
||||
visiting.add(item.entityKey);
|
||||
const safe = planDependencyKeys(item).every((key) => {
|
||||
const dependency = byKey.get(key);
|
||||
return !dependency || isSafe(dependency);
|
||||
});
|
||||
visiting.delete(item.entityKey);
|
||||
memo.set(item.entityKey, safe);
|
||||
return safe;
|
||||
};
|
||||
return items.filter((item) => isSafe(item));
|
||||
}
|
||||
|
||||
export function planStatusForItems(items: Array<Pick<AssetImportPlanDraftItem, 'action'>>): AssetImportPlanStatus {
|
||||
return items.some((item) => item.action === 'REVIEW') ? 'REVIEW_REQUIRED' : 'READY';
|
||||
}
|
||||
|
||||
export function summarizePlanItems(items: AssetImportPlanDraftItem[]): Record<string, unknown> {
|
||||
const actionCounts = { create: 0, match: 0, review: 0, ignore: 0 };
|
||||
let directReviewItems = 0;
|
||||
let dependencyReviewItems = 0;
|
||||
let appliedCreateItems = 0;
|
||||
let pendingCreateItems = 0;
|
||||
const byKind: Record<string, { create: number; match: number; review: number; ignore: number; total: number }> = {};
|
||||
for (const item of items) {
|
||||
const action = item.action.toLowerCase() as keyof typeof actionCounts;
|
||||
actionCounts[action] += 1;
|
||||
if (item.action === 'CREATE') {
|
||||
if (item.status === 'APPLIED') appliedCreateItems += 1;
|
||||
else pendingCreateItems += 1;
|
||||
}
|
||||
if (item.action === 'REVIEW') {
|
||||
if (isPlanDependencyReview(item)) dependencyReviewItems += 1;
|
||||
else directReviewItems += 1;
|
||||
}
|
||||
const current = byKind[item.entityKind] ?? { create: 0, match: 0, review: 0, ignore: 0, total: 0 };
|
||||
current[action] += 1;
|
||||
current.total += 1;
|
||||
byKind[item.entityKind] = current;
|
||||
}
|
||||
const safeCreateItems = safePlanItems(items).filter((item) => item.action === 'CREATE' && item.status !== 'APPLIED').length;
|
||||
return {
|
||||
totalItems: items.length,
|
||||
createItems: actionCounts.create,
|
||||
matchItems: actionCounts.match,
|
||||
reviewItems: actionCounts.review,
|
||||
directReviewItems,
|
||||
dependencyReviewItems,
|
||||
ignoreItems: actionCounts.ignore,
|
||||
appliedCreateItems,
|
||||
pendingCreateItems,
|
||||
safeCreateItems,
|
||||
blockedCreateItems: Math.max(0, pendingCreateItems - safeCreateItems),
|
||||
partialApplied: appliedCreateItems > 0 && actionCounts.review > 0,
|
||||
byKind,
|
||||
blocked: actionCounts.review > 0,
|
||||
};
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { ParsedSheet, ParsedWorkbook } from './asset-import-parser';
|
||||
|
||||
export type AssetImportProfileCode = 'MENDOZA_INVENTORY_V1' | 'MENDOZA_YACIMIENTOS_V1' | 'UNKNOWN';
|
||||
export type AssetImportRowStatus = 'READY' | 'WARNING' | 'CONFLICT' | 'IGNORED';
|
||||
export type AssetImportSuggestedAction = 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
|
||||
|
||||
export interface ImportProfileDetection {
|
||||
profileCode: AssetImportProfileCode;
|
||||
confidence: number;
|
||||
sheetName: string;
|
||||
headerRow: number;
|
||||
columnMap: Record<string, number>;
|
||||
detectedHeaders: string[];
|
||||
}
|
||||
|
||||
export interface ImportNormalizedRow {
|
||||
profileCode: AssetImportProfileCode;
|
||||
rowNumber: number;
|
||||
sheetName: string;
|
||||
raw: Record<string, string>;
|
||||
normalized: Record<string, unknown>;
|
||||
status: AssetImportRowStatus;
|
||||
suggestedAction: AssetImportSuggestedAction;
|
||||
issues: string[];
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export interface ImportAnalysis {
|
||||
detection: ImportProfileDetection;
|
||||
rows: ImportNormalizedRow[];
|
||||
summary: {
|
||||
totalRows: number;
|
||||
readyRows: number;
|
||||
warningRows: number;
|
||||
conflictRows: number;
|
||||
ignoredRows: number;
|
||||
issueCounts: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
function plain(value: string): string {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function compact(value: string): string {
|
||||
return plain(value).replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
const inventoryAliases: Record<string, string[]> = {
|
||||
item: ['item', 'n item', 'numero item'],
|
||||
areaOrField: ['area yacimiento', 'area/yacimiento', 'area y yacimiento', 'area yacimiento '],
|
||||
installation: ['instalacion', 'intalacion'],
|
||||
subInstallation: ['sub instalacion', 'subinstalacion'],
|
||||
equipment: ['equipo'],
|
||||
equipmentDenomination: ['denominacion del equipo', 'denominacion equipo'],
|
||||
location: ['ubicacion del equipo o instalacion', 'ubicacion equipo o instalacion', 'ubicacion'],
|
||||
inventoryId: ['n id inventario', 'n° id inventario', 'nº id inventario', 'id inventario', 'numero id inventario'],
|
||||
quantity: ['cantidad'],
|
||||
technicalSpecs: ['especificaciones tecnicas', 'especificacion tecnica'],
|
||||
sourceStatus: ['estado en servicio fuera de servicio', 'estado', 'estado servicio'],
|
||||
};
|
||||
|
||||
const fieldAliases: Record<string, string[]> = {
|
||||
field: ['yacimiento', 'nombre yacimiento'],
|
||||
area: ['area', 'area hidrocarburifera'],
|
||||
department: ['departamento'],
|
||||
rightType: ['tipo concesion', 'tipo de concesion', 'tipo permiso', 'tipo'],
|
||||
operator: ['operadora', 'operador', 'empresa operadora'],
|
||||
};
|
||||
|
||||
function aliasScore(header: string, aliases: string[]): number {
|
||||
const normalized = plain(header);
|
||||
const normalizedCompact = compact(header);
|
||||
let best = 0;
|
||||
for (const alias of aliases) {
|
||||
const a = plain(alias);
|
||||
const ac = compact(alias);
|
||||
if (normalized === a || normalizedCompact === ac) best = Math.max(best, 10 + a.length / 100);
|
||||
else if (normalized.includes(a) || a.includes(normalized)) best = Math.max(best, 6 + Math.min(a.length, normalized.length) / 100);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function mapHeaders(headers: string[], aliases: Record<string, string[]>): { map: Record<string, number>; score: number } {
|
||||
const candidates: Array<{ field: string; index: number; score: number }> = [];
|
||||
headers.forEach((header, index) => {
|
||||
Object.entries(aliases).forEach(([field, values]) => {
|
||||
const score = aliasScore(header, values);
|
||||
if (score > 0) candidates.push({ field, index, score });
|
||||
});
|
||||
});
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
const usedFields = new Set<string>();
|
||||
const usedIndexes = new Set<number>();
|
||||
const map: Record<string, number> = {};
|
||||
let score = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (usedFields.has(candidate.field) || usedIndexes.has(candidate.index)) continue;
|
||||
usedFields.add(candidate.field);
|
||||
usedIndexes.add(candidate.index);
|
||||
map[candidate.field] = candidate.index;
|
||||
score += candidate.score;
|
||||
}
|
||||
return { map, score };
|
||||
}
|
||||
|
||||
function bestHeader(sheet: ParsedSheet, aliases: Record<string, string[]>): { row: number; map: Record<string, number>; score: number; headers: string[] } {
|
||||
let best = { row: 0, map: {} as Record<string, number>, score: -1, headers: [] as string[] };
|
||||
for (let index = 0; index < Math.min(sheet.rows.length, 30); index += 1) {
|
||||
const headers = sheet.rows[index] ?? [];
|
||||
const mapped = mapHeaders(headers, aliases);
|
||||
if (mapped.score > best.score) best = { row: index + 1, map: mapped.map, score: mapped.score, headers };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function detectImportProfile(workbook: ParsedWorkbook): ImportProfileDetection {
|
||||
let best: ImportProfileDetection = { profileCode: 'UNKNOWN', confidence: 0, sheetName: workbook.sheets[0]?.name ?? 'Hoja', headerRow: 1, columnMap: {}, detectedHeaders: [] };
|
||||
for (const sheet of workbook.sheets) {
|
||||
const inventory = bestHeader(sheet, inventoryAliases);
|
||||
const inventoryRequired = ['areaOrField', 'installation', 'equipment', 'inventoryId'];
|
||||
const inventoryHits = inventoryRequired.filter((field) => inventory.map[field] !== undefined).length;
|
||||
const inventoryConfidence = Math.min(100, Math.round((inventory.score / 95) * 100));
|
||||
if (inventoryHits >= 3 && inventoryConfidence > best.confidence) {
|
||||
best = { profileCode: 'MENDOZA_INVENTORY_V1', confidence: inventoryConfidence, sheetName: sheet.name, headerRow: inventory.row, columnMap: inventory.map, detectedHeaders: inventory.headers };
|
||||
}
|
||||
|
||||
const fields = bestHeader(sheet, fieldAliases);
|
||||
const fieldRequired = ['field', 'area', 'operator'];
|
||||
const fieldHits = fieldRequired.filter((field) => fields.map[field] !== undefined).length;
|
||||
const fieldConfidence = Math.min(100, Math.round((fields.score / 55) * 100));
|
||||
if (fieldHits >= 2 && fieldConfidence > best.confidence) {
|
||||
best = { profileCode: 'MENDOZA_YACIMIENTOS_V1', confidence: fieldConfidence, sheetName: sheet.name, headerRow: fields.row, columnMap: fields.map, detectedHeaders: fields.headers };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function text(row: string[], index: number | undefined): string {
|
||||
return index === undefined ? '' : (row[index] ?? '').trim();
|
||||
}
|
||||
|
||||
function asNumber(value: string): number | null {
|
||||
if (!value.trim()) return null;
|
||||
const normalized = value.trim().replace(/\s+/g, '').replace(',', '.');
|
||||
const number = Number(normalized);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function inventoryStatusSuggestion(value: string): { operationalStatus?: string; conditionStatus?: string; ambiguous: boolean } {
|
||||
const normalized = plain(value);
|
||||
if (!normalized) return { ambiguous: false };
|
||||
if (['en servicio', 'servicio', 'operativo', 'operativa'].includes(normalized)) return { operationalStatus: 'IN_SERVICE', ambiguous: false };
|
||||
if (['fuera de servicio', 'f servicio', 'f serv', 'fs', 'f s'].includes(normalized)) return { operationalStatus: 'OUT_OF_SERVICE', ambiguous: false };
|
||||
if (['bueno', 'buena'].includes(normalized)) return { conditionStatus: 'GOOD', ambiguous: true };
|
||||
if (['regular'].includes(normalized)) return { conditionStatus: 'FAIR', ambiguous: true };
|
||||
if (['malo', 'mala'].includes(normalized)) return { conditionStatus: 'POOR', ambiguous: true };
|
||||
if (['si', 'no', 's', 'n'].includes(normalized)) return { ambiguous: true };
|
||||
return { ambiguous: true };
|
||||
}
|
||||
|
||||
function splitClassType(value: string): { sourceClass: string | null; sourceSubtype: string | null } {
|
||||
const classMatch = /clase\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||
const typeMatch = /tipo\s*:\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||
return { sourceClass: classMatch, sourceSubtype: typeMatch };
|
||||
}
|
||||
|
||||
function splitManufacturerModel(value: string): { manufacturer: string | null; model: string | null } {
|
||||
const manufacturer = /fabricante\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||
const model = /modelo\s*[;:]\s*([^;|]+)/i.exec(value)?.[1]?.trim() ?? null;
|
||||
if (manufacturer || model) return { manufacturer, model };
|
||||
const parts = value.split('|').map((part) => part.trim()).filter(Boolean);
|
||||
if (parts.length >= 3) return { manufacturer: parts[1] ?? null, model: parts[2] ?? null };
|
||||
return { manufacturer: null, model: null };
|
||||
}
|
||||
|
||||
export interface TechnicalNormalizationSuggestion {
|
||||
family: string | null;
|
||||
subtype: string | null;
|
||||
}
|
||||
|
||||
function technicalNormalizationSuggestion(
|
||||
equipment: string,
|
||||
denomination: string,
|
||||
sourceClass: string | null,
|
||||
sourceSubtype: string | null,
|
||||
): TechnicalNormalizationSuggestion {
|
||||
const source = plain(`${sourceClass ?? ''} ${sourceSubtype ?? ''} ${equipment} ${denomination}`);
|
||||
const classCode = plain(sourceClass ?? '');
|
||||
const subtypeCode = plain(sourceSubtype ?? '');
|
||||
|
||||
if (/rectificador/.test(plain(equipment))) return { family: 'equipo_electrico', subtype: 'rectificador' };
|
||||
const equipmentOnly = plain(equipment);
|
||||
if (/^psv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'seguridad' };
|
||||
if (/^vpsv$|^vpv$/.test(equipmentOnly)) return { family: 'valvula', subtype: 'presion_vacio' };
|
||||
if (/^aib$/.test(equipmentOnly)) return { family: 'sistema_extraccion', subtype: 'aib' };
|
||||
if (/seccionador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'seccionador' };
|
||||
if (/interruptor/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'interruptor' };
|
||||
if (/reconectador/.test(equipmentOnly)) return { family: 'equipo_electrico', subtype: 'reconectador' };
|
||||
if (/^colector$/.test(equipmentOnly)) return { family: 'colector', subtype: null };
|
||||
if (/punto de medicion/.test(equipmentOnly)) return { family: 'instrumentacion', subtype: 'punto_medicion' };
|
||||
if (/sistema rci|^rci$/.test(equipmentOnly)) return { family: 'defensa_incendios', subtype: null };
|
||||
if (/aeroenfriador/.test(equipmentOnly)) return { family: 'aeroenfriador', subtype: null };
|
||||
if (/^bateria$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'bateria' };
|
||||
if (/^planta$/.test(equipmentOnly)) return { family: 'instalacion', subtype: 'planta' };
|
||||
if (classCode === 'bba' || /\bbomba\b/.test(source)) {
|
||||
if (/\bcen\b|centrifug/.test(source)) return { family: 'bomba', subtype: 'centrifuga' };
|
||||
if (/\btx\b|triplex|quintuplex|alternativa/.test(source)) return { family: 'bomba', subtype: 'alternativa' };
|
||||
if (/tornillo/.test(source)) return { family: 'bomba', subtype: 'tornillo' };
|
||||
if (/diafragma/.test(source)) return { family: 'bomba', subtype: 'diafragma' };
|
||||
return { family: 'bomba', subtype: null };
|
||||
}
|
||||
if (classCode === 'tk' || /\btanque\b|\btk\b/.test(source)) return { family: 'tanque', subtype: null };
|
||||
if (classCode === 'sep' || /separador/.test(source)) {
|
||||
if (/sep b|bifasic/.test(source)) return { family: 'separador', subtype: 'bifasico' };
|
||||
if (/sep g|gas/.test(source)) return { family: 'separador', subtype: 'gas' };
|
||||
return { family: 'separador', subtype: null };
|
||||
}
|
||||
if (classCode === 'cald' || /\bcaldera\b/.test(source)) return { family: 'caldera', subtype: null };
|
||||
if (classCode === 'cal' || /calentador|hot oil/.test(source)) return { family: 'calentador', subtype: null };
|
||||
if (classCode === 'ant' || /antorcha|flare/.test(source)) return { family: 'antorcha', subtype: /frio/.test(source) ? 'venteo_frio' : null };
|
||||
if (classCode === 'fil' || /\bfiltro\b/.test(source)) return { family: 'filtro', subtype: /arena/.test(source) ? 'arena' : null };
|
||||
if (classCode === 'tra' || /transformador/.test(source)) return { family: 'equipo_electrico', subtype: 'transformador' };
|
||||
if (classCode === 'moe' || /motor electr/.test(source)) return { family: 'motor', subtype: 'electrico' };
|
||||
if (classCode === 'moex' || /motor de combustion|motor a explosion/.test(source)) return { family: 'motor', subtype: 'combustion' };
|
||||
if (classCode === 'com' || /compresor|soplador/.test(source)) return { family: 'compresor', subtype: null };
|
||||
if (classCode === 'va' || /valvula/.test(source)) {
|
||||
if (/vpv|presion y vacio/.test(source)) return { family: 'valvula', subtype: 'presion_vacio' };
|
||||
if (/\bvs\b|seguridad/.test(source)) return { family: 'valvula', subtype: 'seguridad' };
|
||||
if (/\bvr\b|reguladora/.test(source)) return { family: 'valvula', subtype: 'reguladora' };
|
||||
return { family: 'valvula', subtype: null };
|
||||
}
|
||||
if (classCode === 'caud' || /caudalimetro/.test(source)) return { family: 'instrumentacion', subtype: 'caudalimetro' };
|
||||
if (classCode === 'eg' || /generador|motogenerador/.test(source)) return { family: 'generador', subtype: null };
|
||||
if (classCode === 'cel' || /\bcelda\b/.test(source)) return { family: 'equipo_electrico', subtype: 'celda' };
|
||||
if (classCode === 'pil' || /pileta/.test(source)) return { family: 'pileta', subtype: null };
|
||||
if (classCode === 'aib' || /aparato individual de bombeo|rotaflex/.test(source)) {
|
||||
const subtype = /rotaflex/.test(source) ? 'rotaflex' : /mark ii/.test(source) ? 'mark_ii' : /convencional/.test(source) ? 'convencional' : null;
|
||||
return { family: 'sistema_extraccion', subtype };
|
||||
}
|
||||
if (classCode === 'pcp' || /\bpcp\b/.test(source)) return { family: 'sistema_extraccion', subtype: 'pcp' };
|
||||
if (/\bbes\b|electrosumerg/.test(source)) return { family: 'sistema_extraccion', subtype: 'bes' };
|
||||
if (/\bpozo\b/.test(source)) return { family: 'pozo', subtype: null };
|
||||
if (/oleoducto/.test(source)) return { family: 'ducto', subtype: 'oleoducto' };
|
||||
if (/gasoducto/.test(source)) return { family: 'ducto', subtype: 'gasoducto' };
|
||||
if (/acueducto/.test(source)) return { family: 'ducto', subtype: 'acueducto' };
|
||||
if (/caneria/.test(source)) return { family: 'ducto', subtype: 'caneria' };
|
||||
return { family: null, subtype: null };
|
||||
}
|
||||
function rawObject(headers: string[], row: string[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((header, index) => {
|
||||
if (!header.trim() && !row[index]?.trim()) return;
|
||||
result[header.trim() || `Columna ${index + 1}`] = row[index]?.trim() ?? '';
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function fingerprint(value: Record<string, unknown>): string {
|
||||
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeInventory(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] {
|
||||
const headers = sheet.rows[detection.headerRow - 1] ?? [];
|
||||
const result: ImportNormalizedRow[] = [];
|
||||
const map = detection.columnMap;
|
||||
for (let index = detection.headerRow; index < sheet.rows.length; index += 1) {
|
||||
const row = sheet.rows[index] ?? [];
|
||||
if (!row.some((value) => value.trim())) continue;
|
||||
const areaOrField = text(row, map.areaOrField);
|
||||
const installation = text(row, map.installation);
|
||||
const subInstallation = text(row, map.subInstallation);
|
||||
const equipment = text(row, map.equipment);
|
||||
const denomination = text(row, map.equipmentDenomination);
|
||||
const location = text(row, map.location);
|
||||
const inventoryId = text(row, map.inventoryId);
|
||||
const quantityRaw = text(row, map.quantity);
|
||||
const technicalSpecs = text(row, map.technicalSpecs);
|
||||
const sourceStatus = text(row, map.sourceStatus);
|
||||
const item = text(row, map.item);
|
||||
const isHeaderRepeat = compact(areaOrField) === compact(headers[map.areaOrField ?? -1] ?? '') && compact(equipment) === compact(headers[map.equipment ?? -1] ?? '');
|
||||
if (isHeaderRepeat) continue;
|
||||
const issues: string[] = [];
|
||||
if (!areaOrField) issues.push('MISSING_AREA_OR_YACIMIENTO');
|
||||
if (!equipment && !denomination) issues.push('MISSING_EQUIPMENT_DESCRIPTION');
|
||||
if (!inventoryId) issues.push('MISSING_INVENTORY_ID');
|
||||
const quantity = asNumber(quantityRaw);
|
||||
if (quantity !== null && (!Number.isInteger(quantity) || quantity <= 0)) issues.push('INVALID_QUANTITY');
|
||||
if (quantity !== null && quantity > 1) issues.push('GROUPED_QUANTITY');
|
||||
const sourceState = inventoryStatusSuggestion(sourceStatus);
|
||||
if (sourceState.ambiguous && sourceStatus) issues.push('SOURCE_STATUS_REQUIRES_MAPPING');
|
||||
const classType = splitClassType(denomination);
|
||||
const manufacturerModel = splitManufacturerModel(technicalSpecs);
|
||||
const technical = technicalNormalizationSuggestion(equipment, denomination, classType.sourceClass, classType.sourceSubtype);
|
||||
const normalized: Record<string, unknown> = {
|
||||
item: item || null,
|
||||
areaOrField: areaOrField || null,
|
||||
installation: installation || null,
|
||||
subInstallation: subInstallation || null,
|
||||
equipment: equipment || null,
|
||||
sourceClassification: denomination || null,
|
||||
location: location || null,
|
||||
inventoryId: inventoryId || null,
|
||||
quantity: quantity ?? (quantityRaw || null),
|
||||
technicalSpecs: technicalSpecs || null,
|
||||
sourceStatus: sourceStatus || null,
|
||||
operationalStatusSuggestion: sourceState.operationalStatus ?? null,
|
||||
conditionStatusSuggestion: sourceState.conditionStatus ?? null,
|
||||
sourceClass: classType.sourceClass,
|
||||
sourceSubtype: classType.sourceSubtype,
|
||||
manufacturer: manufacturerModel.manufacturer,
|
||||
model: manufacturerModel.model,
|
||||
familySuggestion: technical.family,
|
||||
normalizedFamily: technical.family,
|
||||
normalizedSubtype: technical.subtype,
|
||||
};
|
||||
const conflict = issues.includes('MISSING_EQUIPMENT_DESCRIPTION') || issues.includes('INVALID_QUANTITY');
|
||||
const status: AssetImportRowStatus = conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY';
|
||||
result.push({
|
||||
profileCode: 'MENDOZA_INVENTORY_V1',
|
||||
rowNumber: index + 1,
|
||||
sheetName: sheet.name,
|
||||
raw: rawObject(headers, row),
|
||||
normalized,
|
||||
status,
|
||||
suggestedAction: conflict ? 'REVIEW' : 'CREATE',
|
||||
issues,
|
||||
fingerprint: fingerprint(normalized),
|
||||
});
|
||||
}
|
||||
return applyBatchDuplicateRules(result);
|
||||
}
|
||||
|
||||
function applyBatchDuplicateRules(rows: ImportNormalizedRow[]): ImportNormalizedRow[] {
|
||||
const groups = new Map<string, ImportNormalizedRow[]>();
|
||||
for (const row of rows) {
|
||||
const id = String(row.normalized.inventoryId ?? '').trim().toLowerCase();
|
||||
if (!id) continue;
|
||||
const area = plain(String(row.normalized.areaOrField ?? ''));
|
||||
const key = `${area}|${id}`;
|
||||
const current = groups.get(key) ?? [];
|
||||
current.push(row);
|
||||
groups.set(key, current);
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
if (group.length < 2) continue;
|
||||
const locations = new Set(group.map((row) => plain(`${row.normalized.installation ?? ''}|${row.normalized.subInstallation ?? ''}|${row.normalized.location ?? ''}`)));
|
||||
const issue = locations.size > 1 ? 'INVENTORY_ID_MULTIPLE_LOCATIONS' : 'DUPLICATE_INVENTORY_ID_IN_BATCH';
|
||||
for (const row of group) {
|
||||
if (!row.issues.includes(issue)) row.issues.push(issue);
|
||||
if (issue === 'INVENTORY_ID_MULTIPLE_LOCATIONS') {
|
||||
row.status = 'CONFLICT';
|
||||
row.suggestedAction = 'REVIEW';
|
||||
} else if (row.status === 'READY') {
|
||||
row.status = 'WARNING';
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function normalizeFields(sheet: ParsedSheet, detection: ImportProfileDetection): ImportNormalizedRow[] {
|
||||
const headers = sheet.rows[detection.headerRow - 1] ?? [];
|
||||
const result: ImportNormalizedRow[] = [];
|
||||
const map = detection.columnMap;
|
||||
for (let index = detection.headerRow; index < sheet.rows.length; index += 1) {
|
||||
const row = sheet.rows[index] ?? [];
|
||||
if (!row.some((value) => value.trim())) continue;
|
||||
const field = text(row, map.field);
|
||||
const area = text(row, map.area);
|
||||
const department = text(row, map.department);
|
||||
const rightType = text(row, map.rightType);
|
||||
const operator = text(row, map.operator);
|
||||
const issues: string[] = [];
|
||||
if (!field) issues.push('MISSING_FIELD');
|
||||
if (!area) issues.push('MISSING_AREA');
|
||||
if (!operator) issues.push('MISSING_OPERATOR');
|
||||
const normalized: Record<string, unknown> = {
|
||||
field: field || null,
|
||||
area: area || null,
|
||||
department: department || null,
|
||||
rightType: rightType || null,
|
||||
operator: operator || null,
|
||||
};
|
||||
const conflict = !field || !area;
|
||||
result.push({
|
||||
profileCode: 'MENDOZA_YACIMIENTOS_V1',
|
||||
rowNumber: index + 1,
|
||||
sheetName: sheet.name,
|
||||
raw: rawObject(headers, row),
|
||||
normalized,
|
||||
status: conflict ? 'CONFLICT' : issues.length ? 'WARNING' : 'READY',
|
||||
suggestedAction: conflict ? 'REVIEW' : 'CREATE',
|
||||
issues,
|
||||
fingerprint: fingerprint(normalized),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarize(rows: ImportNormalizedRow[]): ImportAnalysis['summary'] {
|
||||
const issueCounts: Record<string, number> = {};
|
||||
rows.forEach((row) => row.issues.forEach((issue) => { issueCounts[issue] = (issueCounts[issue] ?? 0) + 1; }));
|
||||
return {
|
||||
totalRows: rows.length,
|
||||
readyRows: rows.filter((row) => row.status === 'READY').length,
|
||||
warningRows: rows.filter((row) => row.status === 'WARNING').length,
|
||||
conflictRows: rows.filter((row) => row.status === 'CONFLICT').length,
|
||||
ignoredRows: rows.filter((row) => row.status === 'IGNORED').length,
|
||||
issueCounts,
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeImportWorkbook(workbook: ParsedWorkbook, forcedProfile?: AssetImportProfileCode): ImportAnalysis {
|
||||
const detected = detectImportProfile(workbook);
|
||||
const profileCode = forcedProfile && forcedProfile !== 'UNKNOWN' ? forcedProfile : detected.profileCode;
|
||||
if (profileCode === 'UNKNOWN') {
|
||||
return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } };
|
||||
}
|
||||
const aliases = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? fieldAliases : inventoryAliases;
|
||||
let selected: { sheet: ParsedSheet; header: ReturnType<typeof bestHeader> } | null = null;
|
||||
for (const candidate of workbook.sheets) {
|
||||
const header = bestHeader(candidate, aliases);
|
||||
if (!selected || header.score > selected.header.score) selected = { sheet: candidate, header };
|
||||
}
|
||||
if (!selected) {
|
||||
return { detection: detected, rows: [], summary: { totalRows: 0, readyRows: 0, warningRows: 0, conflictRows: 0, ignoredRows: 0, issueCounts: { PROFILE_NOT_RECOGNIZED: 1 } } };
|
||||
}
|
||||
const denominator = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? 55 : 95;
|
||||
const confidence = Math.min(100, Math.max(0, Math.round((selected.header.score / denominator) * 100)));
|
||||
const detection: ImportProfileDetection = { profileCode, confidence, sheetName: selected.sheet.name, headerRow: selected.header.row, columnMap: selected.header.map, detectedHeaders: selected.header.headers };
|
||||
const rows = profileCode === 'MENDOZA_YACIMIENTOS_V1' ? normalizeFields(selected.sheet, detection) : normalizeInventory(selected.sheet, detection);
|
||||
return { detection, rows, summary: summarize(rows) };
|
||||
}
|
||||
|
||||
export function profileLabel(code: AssetImportProfileCode): string {
|
||||
if (code === 'MENDOZA_INVENTORY_V1') return 'Inventario de instalaciones · Mendoza';
|
||||
if (code === 'MENDOZA_YACIMIENTOS_V1') return 'Tabla Área / Yacimiento · Mendoza';
|
||||
return 'Formato no reconocido';
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { AssetImportsService, MAX_ASSET_IMPORT_BYTES, type UploadedImportFile } from './asset-imports.service';
|
||||
import { ApplyAssetImportPlanDto } from './dto/apply-asset-import-plan.dto';
|
||||
import { CancelAssetImportDto } from './dto/cancel-asset-import.dto';
|
||||
import { CreateAssetImportPlanDto } from './dto/create-asset-import-plan.dto';
|
||||
import { ResolveAssetImportPlanItemDto } from './dto/resolve-asset-import-plan-item.dto';
|
||||
import { RollbackAssetImportPlanDto } from './dto/rollback-asset-import-plan.dto';
|
||||
import { ListAssetImportPlanItemsQueryDto } from './dto/list-asset-import-plan-items-query.dto';
|
||||
import { ListAssetImportReviewsQueryDto } from './dto/list-asset-import-reviews-query.dto';
|
||||
import { ListAssetImportRowsQueryDto } from './dto/list-asset-import-rows-query.dto';
|
||||
import { ListAssetImportsQueryDto } from './dto/list-asset-imports-query.dto';
|
||||
import { UploadAssetImportDto } from './dto/upload-asset-import.dto';
|
||||
|
||||
@Controller('asset-imports')
|
||||
export class AssetImportsController {
|
||||
constructor(private readonly imports: AssetImportsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('asset_imports.read')
|
||||
list(@Query() query: ListAssetImportsQueryDto) {
|
||||
return this.imports.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ASSET_IMPORT_BYTES, files: 1 } }))
|
||||
upload(
|
||||
@Body() dto: UploadAssetImportDto,
|
||||
@UploadedFile() file: UploadedImportFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.upload(dto, file, principal, request);
|
||||
}
|
||||
|
||||
@Get('context/organizations')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
organizations(@Query('search') search?: string) {
|
||||
return this.imports.organizationOptions(search);
|
||||
}
|
||||
|
||||
@Get('reviews')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
reviews(@Query() query: ListAssetImportReviewsQueryDto) {
|
||||
return this.imports.reviews(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.imports.get(id);
|
||||
}
|
||||
|
||||
@Get(':id/plan')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
plan(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.imports.plan(id);
|
||||
}
|
||||
|
||||
@Get(':id/plan/items')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
planItems(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Query() query: ListAssetImportPlanItemsQueryDto,
|
||||
) {
|
||||
return this.imports.planItems(id, query);
|
||||
}
|
||||
|
||||
@Post(':id/plan')
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
generatePlan(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: CreateAssetImportPlanDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.generatePlan(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/plan/items/:itemId/resolve')
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
resolvePlanItem(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Param('itemId', new ParseUUIDPipe({ version: '4' })) itemId: string,
|
||||
@Body() dto: ResolveAssetImportPlanItemDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.resolvePlanItem(id, itemId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/apply-safe')
|
||||
@RequirePermissions('asset_imports.apply')
|
||||
applySafePlan(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ApplyAssetImportPlanDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.applySafePlan(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/apply')
|
||||
@RequirePermissions('asset_imports.apply')
|
||||
applyPlan(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ApplyAssetImportPlanDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.applyPlan(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/rollback')
|
||||
@RequirePermissions('asset_imports.apply')
|
||||
rollbackPlan(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: RollbackAssetImportPlanDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.rollbackPlan(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id/rows')
|
||||
@RequirePermissions('asset_imports.read')
|
||||
rows(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Query() query: ListAssetImportRowsQueryDto,
|
||||
) {
|
||||
return this.imports.rows(id, query);
|
||||
}
|
||||
|
||||
@Post(':id/reconcile')
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
reconcile(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.reconcile(id, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@RequirePermissions('asset_imports.manage')
|
||||
cancel(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: CancelAssetImportDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.imports.cancel(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AssetImportsController } from './asset-imports.controller';
|
||||
import { AssetImportsService } from './asset-imports.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [AssetImportsController],
|
||||
providers: [AssetImportsService],
|
||||
})
|
||||
export class AssetImportsModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
export class ApplyAssetImportPlanDto {
|
||||
@IsString()
|
||||
@Matches(/^[0-9a-f]{64}$/)
|
||||
planHash!: string;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CancelAssetImportDto {
|
||||
@IsString() @MinLength(5) @MaxLength(500) reason!: string;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { IsOptional, IsString, IsUUID, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateAssetImportPlanDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
operatorAssetId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
@Matches(/^[A-Z0-9][A-Z0-9._/-]{1,79}$/i)
|
||||
externalIdNamespace?: string;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
const entityKinds = [
|
||||
'DEPARTMENT','ORGANIZATION','AREA','AREA_DEPARTMENT_RELATION','FIELD','OPERATOR_RELATION',
|
||||
'LEGAL_RIGHT','LEGAL_RIGHT_ORGANIZATION','INSTALLATION','LOCAL_STRUCTURE','TECHNICAL_ASSET',
|
||||
] as const;
|
||||
|
||||
export class ListAssetImportPlanItemsQueryDto {
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
|
||||
@IsOptional() @IsIn(entityKinds) entityKind?: typeof entityKinds[number];
|
||||
@IsOptional() @IsIn(['CREATE','MATCH','REVIEW','IGNORE']) action?: 'CREATE' | 'MATCH' | 'REVIEW' | 'IGNORE';
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListAssetImportReviewsQueryDto {
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 30;
|
||||
@IsOptional() @IsIn(['ALL','DIRECT','DEPENDENCY']) kind: 'ALL' | 'DIRECT' | 'DEPENDENCY' = 'ALL';
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class ListAssetImportRowsQueryDto {
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize = 50;
|
||||
@IsOptional() @IsIn(['READY','WARNING','CONFLICT','IGNORED']) status?: string;
|
||||
@IsOptional() @IsString() @MaxLength(160) search?: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListAssetImportsQueryDto {
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page = 1;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) pageSize = 20;
|
||||
@IsOptional() @IsIn(['ANALYZED','REVIEW_REQUIRED','CANCELLED','FAILED']) status?: string;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { IsIn, IsOptional, IsString, IsUUID, MaxLength, MinLength, ValidateIf } from 'class-validator';
|
||||
|
||||
export class ResolveAssetImportPlanItemDto {
|
||||
@IsIn(['CREATE', 'MATCH', 'IGNORE'])
|
||||
action!: 'CREATE' | 'MATCH' | 'IGNORE';
|
||||
|
||||
@ValidateIf((value: ResolveAssetImportPlanItemDto) => value.action === 'MATCH')
|
||||
@IsUUID('4')
|
||||
matchedAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(5)
|
||||
@MaxLength(1000)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class RollbackAssetImportPlanDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(1000)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import type { AssetImportProfileCode } from '../asset-import-profiles';
|
||||
|
||||
export class UploadAssetImportDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(240)
|
||||
sourceLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['MENDOZA_INVENTORY_V1', 'MENDOZA_YACIMIENTOS_V1'])
|
||||
profileCode?: Exclude<AssetImportProfileCode, 'UNKNOWN'>;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
AssetAttributeDataType,
|
||||
AssetAttributeDefinition,
|
||||
} from '../database/entities';
|
||||
|
||||
export interface NormalizedAttributeValue {
|
||||
definitionId: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function invalidAttribute(message: string, definitionId?: string): never {
|
||||
throw new BadRequestException({
|
||||
code: 'INVALID_ASSET_ATTRIBUTE',
|
||||
message,
|
||||
...(definitionId ? { definitionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function isMissing(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
function isValidCalendarDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const [year, month, day] = value.split('-').map(Number);
|
||||
const parsed = new Date(Date.UTC(year!, month! - 1, day));
|
||||
return (
|
||||
parsed.getUTCFullYear() === year &&
|
||||
parsed.getUTCMonth() === month! - 1 &&
|
||||
parsed.getUTCDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeValue(
|
||||
definition: AssetAttributeDefinition,
|
||||
value: unknown,
|
||||
): unknown {
|
||||
switch (definition.dataType) {
|
||||
case AssetAttributeDataType.TEXT:
|
||||
if (typeof value !== 'string' || value.length > 4000) {
|
||||
return invalidAttribute(
|
||||
`El atributo ${definition.name} debe ser un texto de hasta 4000 caracteres`,
|
||||
definition.id,
|
||||
);
|
||||
}
|
||||
return value.trim();
|
||||
|
||||
case AssetAttributeDataType.NUMBER:
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return invalidAttribute(
|
||||
`El atributo ${definition.name} debe ser numérico`,
|
||||
definition.id,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
|
||||
case AssetAttributeDataType.BOOLEAN:
|
||||
if (typeof value !== 'boolean') {
|
||||
return invalidAttribute(
|
||||
`El atributo ${definition.name} debe ser verdadero o falso`,
|
||||
definition.id,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
|
||||
case AssetAttributeDataType.DATE:
|
||||
if (typeof value !== 'string' || !isValidCalendarDate(value)) {
|
||||
return invalidAttribute(
|
||||
`El atributo ${definition.name} debe ser una fecha válida`,
|
||||
definition.id,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
|
||||
case AssetAttributeDataType.DATETIME:
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.trim() ||
|
||||
!Number.isFinite(Date.parse(value))
|
||||
) {
|
||||
return invalidAttribute(
|
||||
`El atributo ${definition.name} debe ser una fecha y hora válida`,
|
||||
definition.id,
|
||||
);
|
||||
}
|
||||
return new Date(value).toISOString();
|
||||
|
||||
case AssetAttributeDataType.SELECT: {
|
||||
const options = definition.options ?? [];
|
||||
if (typeof value !== 'string' || !options.includes(value)) {
|
||||
return invalidAttribute(
|
||||
`El atributo ${definition.name} no contiene una opción válida`,
|
||||
definition.id,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAssetAttributeValues(
|
||||
definitions: AssetAttributeDefinition[],
|
||||
values: Record<string, unknown>,
|
||||
): NormalizedAttributeValue[] {
|
||||
const activeDefinitions = definitions.filter((definition) => definition.isActive);
|
||||
const byId = new Map(activeDefinitions.map((definition) => [definition.id, definition]));
|
||||
|
||||
for (const definitionId of Object.keys(values)) {
|
||||
if (!byId.has(definitionId)) {
|
||||
invalidAttribute('Se recibió un atributo que no pertenece al tipo de activo', definitionId);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized: NormalizedAttributeValue[] = [];
|
||||
for (const definition of activeDefinitions) {
|
||||
const value = values[definition.id];
|
||||
if (isMissing(value)) {
|
||||
if (definition.isRequired) {
|
||||
invalidAttribute(`El atributo ${definition.name} es obligatorio`, definition.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
normalized.push({
|
||||
definitionId: definition.id,
|
||||
value: normalizeValue(definition, value),
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../common/http/request-context';
|
||||
import { AssetGeometriesService } from './asset-geometries.service';
|
||||
import { MapAssetsQueryDto } from './dto/map-assets-query.dto';
|
||||
import { UpsertAssetGeometryDto } from './dto/upsert-asset-geometry.dto';
|
||||
|
||||
@Controller('assets')
|
||||
export class AssetGeometriesController {
|
||||
constructor(private readonly geometries: AssetGeometriesService) {}
|
||||
|
||||
@Get(':id/geometry')
|
||||
@RequirePermissions('assets.read')
|
||||
get(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
) {
|
||||
return this.geometries.get(id);
|
||||
}
|
||||
|
||||
@Put(':id/geometry')
|
||||
@RequirePermissions('assets.update_geometry')
|
||||
upsert(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpsertAssetGeometryDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.geometries.upsert(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Delete(':id/geometry')
|
||||
@RequirePermissions('assets.update_geometry')
|
||||
remove(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.geometries.remove(id, principal, request);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('map/assets')
|
||||
export class MapAssetsController {
|
||||
constructor(private readonly geometries: AssetGeometriesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read')
|
||||
map(@Query() query: MapAssetsQueryDto) {
|
||||
return this.geometries.map(query);
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../common/http/request-context';
|
||||
import {
|
||||
Asset,
|
||||
AssetGeometrySource,
|
||||
AssetGeometryType,
|
||||
AssetVersionChangeType,
|
||||
AuditAction,
|
||||
} from '../database/entities';
|
||||
import {
|
||||
parseBoundingBox,
|
||||
validateGeoJsonGeometry,
|
||||
type GeoJsonGeometry,
|
||||
} from './asset-geometry-validator';
|
||||
import type { MapAssetsQueryDto } from './dto/map-assets-query.dto';
|
||||
import type { UpsertAssetGeometryDto } from './dto/upsert-asset-geometry.dto';
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
|
||||
export interface AssetGeometryView {
|
||||
assetId: string;
|
||||
geometry: GeoJsonGeometry;
|
||||
geometryType: AssetGeometryType;
|
||||
source: AssetGeometrySource;
|
||||
accuracyM: number | null;
|
||||
capturedAt: Date | null;
|
||||
deviceLabel: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
interface MapAssetRow {
|
||||
id: string;
|
||||
geometry: GeoJsonGeometry;
|
||||
code: string;
|
||||
name: string;
|
||||
typeId: string;
|
||||
typeCode: string;
|
||||
typeName: string;
|
||||
parentId: string | null;
|
||||
parentName: string | null;
|
||||
informationStatus: string;
|
||||
geometryType: AssetGeometryType;
|
||||
accuracyM: number | string | null;
|
||||
capturedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function assetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_NOT_FOUND',
|
||||
message: 'Activo no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetGeometriesService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly history: AssetHistoryService,
|
||||
) {}
|
||||
|
||||
async get(assetId: string): Promise<{ data: AssetGeometryView | null }> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.requireAsset(manager, assetId);
|
||||
return { data: await this.loadGeometry(manager, assetId) };
|
||||
});
|
||||
}
|
||||
|
||||
async upsert(
|
||||
assetId: string,
|
||||
dto: UpsertAssetGeometryDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetGeometryView> {
|
||||
const geometry = validateGeoJsonGeometry(dto.geometry);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.requireAsset(manager, assetId, true);
|
||||
const before = await this.loadGeometry(manager, assetId);
|
||||
const source = principal.transport === 'bearer'
|
||||
? AssetGeometrySource.ANDROID
|
||||
: AssetGeometrySource.WEB;
|
||||
|
||||
await manager.query(
|
||||
`INSERT INTO asset_geometries (
|
||||
asset_id, geometry, geometry_type, source, accuracy_m,
|
||||
captured_at, device_label, updated_by
|
||||
) VALUES (
|
||||
$1,
|
||||
ST_SetSRID(ST_GeomFromGeoJSON($2::text), 4326),
|
||||
$3, $4, $5, $6, $7, $8
|
||||
)
|
||||
ON CONFLICT (asset_id) DO UPDATE SET
|
||||
geometry = EXCLUDED.geometry,
|
||||
geometry_type = EXCLUDED.geometry_type,
|
||||
source = EXCLUDED.source,
|
||||
accuracy_m = EXCLUDED.accuracy_m,
|
||||
captured_at = EXCLUDED.captured_at,
|
||||
device_label = EXCLUDED.device_label,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
updated_by = EXCLUDED.updated_by`,
|
||||
[
|
||||
assetId,
|
||||
JSON.stringify(geometry),
|
||||
geometry.type,
|
||||
source,
|
||||
dto.accuracyM ?? null,
|
||||
dto.capturedAt ? new Date(dto.capturedAt) : null,
|
||||
dto.deviceLabel?.trim() || null,
|
||||
principal.userId,
|
||||
],
|
||||
);
|
||||
const updated = await this.loadGeometry(manager, assetId);
|
||||
if (!updated) throw new Error('Asset geometry was not persisted');
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
assetId,
|
||||
AssetVersionChangeType.GEOMETRY_UPDATED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_GEOMETRY_UPDATED,
|
||||
entityType: 'asset',
|
||||
entityId: assetId,
|
||||
beforeData: before ? this.auditGeometry(before) : null,
|
||||
afterData: this.auditGeometry(updated),
|
||||
metadata: { versionNumber },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async remove(
|
||||
assetId: string,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<{ status: 'removed' | 'absent' }> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.requireAsset(manager, assetId, true);
|
||||
const before = await this.loadGeometry(manager, assetId);
|
||||
if (!before) return { status: 'absent' };
|
||||
await manager.query('DELETE FROM asset_geometries WHERE asset_id = $1', [assetId]);
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
assetId,
|
||||
AssetVersionChangeType.GEOMETRY_REMOVED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_GEOMETRY_REMOVED,
|
||||
entityType: 'asset',
|
||||
entityId: assetId,
|
||||
beforeData: this.auditGeometry(before),
|
||||
afterData: { geometry: null },
|
||||
metadata: { versionNumber },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return { status: 'removed' };
|
||||
});
|
||||
}
|
||||
|
||||
async map(query: MapAssetsQueryDto) {
|
||||
const conditions: string[] = [];
|
||||
const parameters: unknown[] = [];
|
||||
const add = (value: unknown): string => {
|
||||
parameters.push(value);
|
||||
return `$${parameters.length}`;
|
||||
};
|
||||
const bbox = parseBoundingBox(query.bbox);
|
||||
if (bbox) {
|
||||
const placeholders = bbox.map((value) => add(value));
|
||||
conditions.push(
|
||||
`ST_Intersects(geometry.geometry, ST_MakeEnvelope(${placeholders.join(', ')}, 4326))`,
|
||||
);
|
||||
}
|
||||
if (query.typeId) conditions.push(`asset.asset_type_id = ${add(query.typeId)}`);
|
||||
if (query.status) conditions.push(`asset.information_status = ${add(query.status)}`);
|
||||
if (query.geometryType) conditions.push(`geometry.geometry_type = ${add(query.geometryType)}`);
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const rows = (await this.dataSource.query(
|
||||
`SELECT
|
||||
asset.id,
|
||||
ST_AsGeoJSON(geometry.geometry)::jsonb AS geometry,
|
||||
asset.code,
|
||||
asset.name,
|
||||
asset_type.id AS "typeId",
|
||||
asset_type.code AS "typeCode",
|
||||
asset_type.name AS "typeName",
|
||||
parent.id AS "parentId",
|
||||
parent.name AS "parentName",
|
||||
asset.information_status AS "informationStatus",
|
||||
geometry.geometry_type AS "geometryType",
|
||||
geometry.accuracy_m AS "accuracyM",
|
||||
geometry.captured_at AS "capturedAt",
|
||||
geometry.updated_at AS "updatedAt"
|
||||
FROM asset_geometries geometry
|
||||
INNER JOIN assets asset ON asset.id = geometry.asset_id
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
LEFT JOIN assets parent ON parent.id = asset.parent_id
|
||||
${where}
|
||||
ORDER BY asset.name, asset.code
|
||||
LIMIT 5001`,
|
||||
parameters,
|
||||
)) as MapAssetRow[];
|
||||
const truncated = rows.length > 5000;
|
||||
const visible = truncated ? rows.slice(0, 5000) : rows;
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: visible.map((row) => ({
|
||||
type: 'Feature' as const,
|
||||
id: row.id,
|
||||
geometry: row.geometry,
|
||||
properties: {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
typeId: row.typeId,
|
||||
typeCode: row.typeCode,
|
||||
typeName: row.typeName,
|
||||
parentId: row.parentId,
|
||||
parentName: row.parentName,
|
||||
informationStatus: row.informationStatus,
|
||||
geometryType: row.geometryType,
|
||||
accuracyM: row.accuracyM == null ? null : Number(row.accuracyM),
|
||||
capturedAt: row.capturedAt,
|
||||
updatedAt: row.updatedAt,
|
||||
},
|
||||
})),
|
||||
meta: { count: visible.length, truncated },
|
||||
};
|
||||
}
|
||||
|
||||
private async requireAsset(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
lock = false,
|
||||
): Promise<void> {
|
||||
if (lock) {
|
||||
const [row] = (await manager.query(
|
||||
'SELECT 1 FROM assets WHERE id = $1 FOR UPDATE',
|
||||
[id],
|
||||
)) as unknown[];
|
||||
if (!row) throw assetNotFound();
|
||||
return;
|
||||
}
|
||||
const exists = await manager.getRepository(Asset).exist({ where: { id } });
|
||||
if (!exists) throw assetNotFound();
|
||||
}
|
||||
|
||||
private async loadGeometry(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
): Promise<AssetGeometryView | null> {
|
||||
const [row] = (await manager.query(
|
||||
`SELECT
|
||||
asset_id AS "assetId",
|
||||
ST_AsGeoJSON(geometry)::jsonb AS geometry,
|
||||
geometry_type AS "geometryType",
|
||||
source,
|
||||
accuracy_m::double precision AS "accuracyM",
|
||||
captured_at AS "capturedAt",
|
||||
device_label AS "deviceLabel",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
updated_by AS "updatedBy"
|
||||
FROM asset_geometries
|
||||
WHERE asset_id = $1`,
|
||||
[assetId],
|
||||
)) as AssetGeometryView[];
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
private auditGeometry(view: AssetGeometryView): Record<string, unknown> {
|
||||
return {
|
||||
geometry: view.geometry,
|
||||
geometryType: view.geometryType,
|
||||
source: view.source,
|
||||
accuracyM: view.accuracyM,
|
||||
capturedAt: view.capturedAt,
|
||||
deviceLabel: view.deviceLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AssetGeometryType } from '../database/entities';
|
||||
|
||||
export interface GeoJsonGeometry {
|
||||
type: AssetGeometryType;
|
||||
coordinates: unknown[];
|
||||
}
|
||||
|
||||
type Position = [number, number];
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new BadRequestException({
|
||||
code: 'INVALID_ASSET_GEOMETRY',
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
function position(value: unknown, label: string): Position {
|
||||
if (!Array.isArray(value) || value.length < 2) {
|
||||
return invalid(`${label} debe contener longitud y latitud`);
|
||||
}
|
||||
const longitude = Number(value[0]);
|
||||
const latitude = Number(value[1]);
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
|
||||
return invalid(`${label} contiene coordenadas no numéricas`);
|
||||
}
|
||||
if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
|
||||
return invalid(`${label} está fuera del rango geográfico válido`);
|
||||
}
|
||||
return [longitude, latitude];
|
||||
}
|
||||
|
||||
function positions(value: unknown, minimum: number, label: string): Position[] {
|
||||
if (!Array.isArray(value) || value.length < minimum) {
|
||||
return invalid(`${label} necesita al menos ${minimum} vértices`);
|
||||
}
|
||||
if (value.length > 10_000) return invalid(`${label} supera el máximo de 10000 vértices`);
|
||||
return value.map((item, index) => position(item, `${label} · vértice ${index + 1}`));
|
||||
}
|
||||
|
||||
function samePosition(first: Position, last: Position): boolean {
|
||||
return first[0] === last[0] && first[1] === last[1];
|
||||
}
|
||||
|
||||
export function validateGeoJsonGeometry(input: GeoJsonGeometry): GeoJsonGeometry {
|
||||
if (!Object.values(AssetGeometryType).includes(input.type)) {
|
||||
return invalid('El tipo de geometría no está permitido');
|
||||
}
|
||||
|
||||
if (input.type === AssetGeometryType.POINT) {
|
||||
return { type: input.type, coordinates: position(input.coordinates, 'El punto') };
|
||||
}
|
||||
|
||||
if (input.type === AssetGeometryType.LINESTRING) {
|
||||
const line = positions(input.coordinates, 2, 'La línea');
|
||||
if (new Set(line.map((item) => item.join(','))).size < 2) {
|
||||
return invalid('La línea necesita al menos dos posiciones diferentes');
|
||||
}
|
||||
return { type: input.type, coordinates: line };
|
||||
}
|
||||
|
||||
if (!Array.isArray(input.coordinates) || input.coordinates.length < 1) {
|
||||
return invalid('El polígono necesita al menos un anillo');
|
||||
}
|
||||
if (input.coordinates.length > 20) return invalid('El polígono supera el máximo de 20 anillos');
|
||||
const rings = input.coordinates.map((ring, ringIndex) => {
|
||||
const normalized = positions(ring, 4, `Anillo ${ringIndex + 1}`);
|
||||
if (!samePosition(normalized[0]!, normalized[normalized.length - 1]!)) {
|
||||
return invalid(`El anillo ${ringIndex + 1} debe estar cerrado`);
|
||||
}
|
||||
if (new Set(normalized.slice(0, -1).map((item) => item.join(','))).size < 3) {
|
||||
return invalid(`El anillo ${ringIndex + 1} necesita tres posiciones diferentes`);
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
return { type: input.type, coordinates: rings };
|
||||
}
|
||||
|
||||
export function parseBoundingBox(value?: string): [number, number, number, number] | null {
|
||||
if (!value) return null;
|
||||
const numbers = value.split(',').map(Number);
|
||||
if (numbers.length !== 4 || numbers.some((item) => !Number.isFinite(item))) {
|
||||
return invalid('El área visible del mapa no es válida');
|
||||
}
|
||||
const [west, south, east, north] = numbers as [number, number, number, number];
|
||||
if (
|
||||
west < -180 || east > 180 || south < -90 || north > 90 ||
|
||||
west >= east || south >= north
|
||||
) {
|
||||
return invalid('El área visible del mapa está fuera de rango');
|
||||
}
|
||||
return [west, south, east, north];
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
import {
|
||||
AssetVersionPageQueryDto,
|
||||
ListAssetVersionsQueryDto,
|
||||
} from './dto/list-asset-versions-query.dto';
|
||||
|
||||
@Controller()
|
||||
export class AssetHistoryController {
|
||||
constructor(private readonly history: AssetHistoryService) {}
|
||||
|
||||
@Get('asset-versions')
|
||||
@RequirePermissions('assets.read_history')
|
||||
list(@Query() query: ListAssetVersionsQueryDto) {
|
||||
return this.history.list(query);
|
||||
}
|
||||
|
||||
@Get('assets/:id/versions')
|
||||
@RequirePermissions('assets.read_history')
|
||||
listForAsset(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Query() query: AssetVersionPageQueryDto,
|
||||
) {
|
||||
return this.history.listForAsset(id, query);
|
||||
}
|
||||
|
||||
@Get('assets/:id/versions/:versionNumber')
|
||||
@RequirePermissions('assets.read_history')
|
||||
getVersion(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Param('versionNumber', new ParseIntPipe()) versionNumber: number,
|
||||
) {
|
||||
return this.history.getVersion(id, versionNumber);
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../common/http/request-context';
|
||||
import {
|
||||
AssetVersionChangeType,
|
||||
AuditSource,
|
||||
} from '../database/entities';
|
||||
import { changedSnapshotFields } from './asset-version-diff';
|
||||
import type {
|
||||
AssetVersionPageQueryDto,
|
||||
ListAssetVersionsQueryDto,
|
||||
} from './dto/list-asset-versions-query.dto';
|
||||
|
||||
export interface AssetVersionSummary {
|
||||
id: string;
|
||||
assetId: string;
|
||||
assetCode: string;
|
||||
assetName: string;
|
||||
typeId: string;
|
||||
typeName: string;
|
||||
informationStatus: string;
|
||||
operationalStatus: string;
|
||||
versionNumber: number;
|
||||
changeType: AssetVersionChangeType;
|
||||
changedFields: string[];
|
||||
occurredAt: Date;
|
||||
actorUserId: string | null;
|
||||
actorUsername: string | null;
|
||||
source: AuditSource;
|
||||
requestId: string | null;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface AssetVersionDetail extends AssetVersionSummary {
|
||||
snapshot: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function assetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_NOT_FOUND',
|
||||
message: 'Inventario no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function versionNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_VERSION_NOT_FOUND',
|
||||
message: 'Versión de Inventario no encontrada',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetHistoryService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async capture(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
changeType: AssetVersionChangeType,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<number> {
|
||||
await manager.query(
|
||||
`UPDATE assets
|
||||
SET current_version = COALESCE(current_version, 0) + 1
|
||||
WHERE id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
const [versionRow] = (await manager.query(
|
||||
`SELECT current_version
|
||||
FROM assets
|
||||
WHERE id = $1`,
|
||||
[assetId],
|
||||
)) as Array<{ current_version: number | string | null }>;
|
||||
if (!versionRow) throw assetNotFound();
|
||||
|
||||
const versionNumber = Number(versionRow.current_version);
|
||||
if (!Number.isInteger(versionNumber) || versionNumber < 1) {
|
||||
throw new Error(`Versión de Inventario inválida después de incrementar: ${versionRow.current_version}`);
|
||||
}
|
||||
|
||||
const snapshot = await this.loadCurrentSnapshot(manager, assetId);
|
||||
const [previousRow] = (await manager.query(
|
||||
`SELECT snapshot
|
||||
FROM asset_versions
|
||||
WHERE asset_id = $1 AND version_number < $2
|
||||
ORDER BY version_number DESC
|
||||
LIMIT 1`,
|
||||
[assetId, versionNumber],
|
||||
)) as Array<{ snapshot: Record<string, unknown> }>;
|
||||
const changedFields = changedSnapshotFields(previousRow?.snapshot ?? null, snapshot);
|
||||
const source = principal.transport === 'bearer'
|
||||
? AuditSource.ANDROID
|
||||
: AuditSource.WEB;
|
||||
|
||||
await manager.query(
|
||||
`INSERT INTO asset_versions (
|
||||
asset_id, version_number, change_type, changed_fields, snapshot,
|
||||
actor_user_id, actor_username, source, request_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[assetId, versionNumber, changeType, changedFields, snapshot,
|
||||
principal.userId, principal.username, source, request.requestId],
|
||||
);
|
||||
return versionNumber;
|
||||
}
|
||||
|
||||
async list(query: ListAssetVersionsQueryDto) {
|
||||
const conditions: string[] = [];
|
||||
const parameters: unknown[] = [];
|
||||
const add = (value: unknown): string => {
|
||||
parameters.push(value);
|
||||
return `$${parameters.length}`;
|
||||
};
|
||||
if (query.search?.trim()) {
|
||||
const search = add(`%${query.search.trim()}%`);
|
||||
conditions.push(`(version.snapshot->>'code' ILIKE ${search} OR version.snapshot->>'name' ILIKE ${search} OR version.actor_username ILIKE ${search})`);
|
||||
}
|
||||
if (query.typeId) conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
||||
if (query.status) conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
||||
if (query.changeType) conditions.push(`version.change_type = ${add(query.changeType)}`);
|
||||
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
|
||||
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
|
||||
return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
|
||||
}
|
||||
|
||||
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
|
||||
await this.requireAsset(assetId);
|
||||
return this.listWithConditions(query.page, query.pageSize, ['version.asset_id = $1'], [assetId]);
|
||||
}
|
||||
|
||||
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
|
||||
await this.requireAsset(assetId);
|
||||
const [row] = (await this.dataSource.query(
|
||||
`${this.selectSummary()}, version.snapshot
|
||||
FROM asset_versions version
|
||||
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
|
||||
WHERE version.asset_id = $1 AND version.version_number = $2`,
|
||||
[assetId, versionNumber],
|
||||
)) as AssetVersionDetail[];
|
||||
if (!row) throw versionNotFound();
|
||||
return row;
|
||||
}
|
||||
|
||||
private async listWithConditions(page: number, pageSize: number, conditions: string[], parameters: unknown[]) {
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [countRow] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, parameters)) as Array<{ total: number }>;
|
||||
const total = Number(countRow?.total ?? 0);
|
||||
const paginated = [...parameters, pageSize, (page - 1) * pageSize];
|
||||
const limit = `$${parameters.length + 1}`;
|
||||
const offset = `$${parameters.length + 2}`;
|
||||
const data = (await this.dataSource.query(
|
||||
`${this.selectSummary()}
|
||||
FROM asset_versions version
|
||||
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
|
||||
${where}
|
||||
ORDER BY version.occurred_at DESC, version.version_number DESC
|
||||
LIMIT ${limit} OFFSET ${offset}`,
|
||||
paginated,
|
||||
)) as AssetVersionSummary[];
|
||||
return { data, meta: { page, pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) } };
|
||||
}
|
||||
|
||||
private selectSummary(): string {
|
||||
return `SELECT
|
||||
version.id, version.asset_id AS "assetId",
|
||||
version.snapshot->>'code' AS "assetCode", version.snapshot->>'name' AS "assetName",
|
||||
version.snapshot #>> '{type,id}' AS "typeId", version.snapshot #>> '{type,name}' AS "typeName",
|
||||
version.snapshot->>'informationStatus' AS "informationStatus",
|
||||
version.snapshot->>'operationalStatus' AS "operationalStatus",
|
||||
version.version_number AS "versionNumber", version.change_type AS "changeType",
|
||||
version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt",
|
||||
version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername",
|
||||
version.source, version.request_id AS "requestId",
|
||||
(version.version_number = current_asset.current_version) AS "isCurrent"`;
|
||||
}
|
||||
|
||||
private async requireAsset(assetId: string): Promise<void> {
|
||||
const [row] = (await this.dataSource.query('SELECT 1 FROM assets WHERE id = $1', [assetId])) as unknown[];
|
||||
if (!row) throw assetNotFound();
|
||||
}
|
||||
|
||||
private async loadCurrentSnapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
|
||||
const [row] = (await manager.query(
|
||||
`SELECT JSONB_BUILD_OBJECT(
|
||||
'id', asset.id,
|
||||
'code', asset.code,
|
||||
'name', asset.name,
|
||||
'commonName', asset.common_name,
|
||||
'description', asset.description,
|
||||
'type', JSONB_BUILD_OBJECT('id', asset_type.id,'code', asset_type.code,'name', asset_type.name,'operationalRole', asset_type.operational_role),
|
||||
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', parent.id,'code', parent.code,'name', parent.name) END,
|
||||
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operational_area.id,'code', operational_area.code,'name', operational_area.name) END,
|
||||
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operator_company.id,'code', operator_company.code,'name', operator_company.name) END,
|
||||
'informationStatus', asset.information_status,
|
||||
'operationalStatus', asset.operational_status,
|
||||
'currentFunction', (
|
||||
SELECT JSONB_BUILD_OBJECT(
|
||||
'assignmentId', assignment.id,
|
||||
'id', fn.id,
|
||||
'code', fn.code,
|
||||
'name', fn.name,
|
||||
'validFrom', assignment.valid_from,
|
||||
'reason', assignment.change_reason
|
||||
)
|
||||
FROM inventory_function_assignments assignment
|
||||
JOIN inventory_functions fn ON fn.id=assignment.function_id
|
||||
WHERE assignment.asset_id=asset.id AND assignment.valid_until IS NULL
|
||||
ORDER BY assignment.valid_from DESC LIMIT 1
|
||||
),
|
||||
'attributes', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'definitionId', definition.id,'code', definition.code,'name', definition.name,
|
||||
'dataType', definition.data_type,'isRequired', definition.is_required,
|
||||
'unit', definition.unit,'options', definition.options,'sortOrder', definition.sort_order,'value', value.value
|
||||
) ORDER BY definition.sort_order, definition.name)
|
||||
FROM asset_attribute_definitions definition
|
||||
LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id
|
||||
WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true
|
||||
), '[]'::jsonb),
|
||||
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'assetId', geometry.asset_id,'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,'geometryType', geometry.geometry_type,
|
||||
'source', geometry.source,'accuracyM', geometry.accuracy_m::double precision,'capturedAt', geometry.captured_at,
|
||||
'deviceLabel', geometry.device_label,'createdAt', geometry.created_at,'updatedAt', geometry.updated_at,'updatedBy', geometry.updated_by
|
||||
) END,
|
||||
'media', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id', media.id,'kind', media.kind,'originalName', media.original_name,'mimeType', media.mime_type,
|
||||
'sizeBytes', media.size_bytes,'sha256', media.sha256,'title', media.title,'description', media.description,
|
||||
'capturedAt', media.captured_at,'latitude', media.latitude,'longitude', media.longitude,'accuracyM', media.accuracy_m,
|
||||
'source', media.source,'uploadedBy', media.uploaded_by,'createdAt', media.created_at,'updatedAt', media.updated_at
|
||||
) ORDER BY media.created_at, media.id)
|
||||
FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL
|
||||
), '[]'::jsonb),
|
||||
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
|
||||
'organizationMemberships', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',m.id,'parentOrganizationId',m.parent_organization_id,'memberOrganizationId',m.member_organization_id,'role',m.role,'participationPercent',m.participation_percent::double precision,'validFrom',m.valid_from,'validUntil',m.valid_until,'sourceDocumentId',m.source_document_id,'notes',m.notes,'endReason',m.end_reason) ORDER BY m.valid_until NULLS FIRST,m.valid_from DESC)
|
||||
FROM organization_memberships m WHERE m.parent_organization_id=asset.id OR m.member_organization_id=asset.id
|
||||
),'[]'::jsonb),
|
||||
'externalIdentifiers', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',i.id,'namespace',i.namespace,'value',i.value,'validFrom',i.valid_from,'validUntil',i.valid_until,'sourceDocumentId',i.source_document_id,'notes',i.notes,'endReason',i.end_reason) ORDER BY i.valid_until NULLS FIRST,i.namespace,i.valid_from DESC) FROM asset_external_identifiers i WHERE i.asset_id=asset.id
|
||||
),'[]'::jsonb),
|
||||
'sourceDocuments', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('linkId',l.id,'relationType',l.relation_type,'documentId',d.id,'documentType',d.document_type,'documentNumber',d.document_number,'title',d.title,'issuer',d.issuer,'documentDate',d.document_date,'externalReference',d.external_reference) ORDER BY d.document_date DESC NULLS LAST,d.created_at DESC) FROM asset_source_documents l JOIN source_documents d ON d.id=l.document_id WHERE l.asset_id=asset.id
|
||||
),'[]'::jsonb),
|
||||
'legalRights', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
|
||||
),'[]'::jsonb),
|
||||
'provenance', JSONB_BUILD_OBJECT('origin', asset.data_origin,'sourceName', asset.source_name,'sourceReference', asset.source_reference,'observedAt', asset.source_observed_at,'notes', asset.source_notes,'verifiedAt', asset.provenance_verified_at,'verifiedBy', asset.provenance_verified_by,'updatedAt', asset.provenance_updated_at,'updatedBy', asset.provenance_updated_by),
|
||||
'createdAt', asset.created_at,'updatedAt', asset.updated_at,'createdBy', asset.created_by,'updatedBy', asset.updated_by,'currentVersion', asset.current_version
|
||||
) AS snapshot
|
||||
FROM assets asset
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
LEFT JOIN assets parent ON parent.id = asset.parent_id
|
||||
LEFT JOIN assets operational_area ON operational_area.id = asset.operational_area_id
|
||||
LEFT JOIN assets operator_company ON operator_company.id = asset.operator_company_id
|
||||
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
|
||||
WHERE asset.id = $1`,
|
||||
[assetId],
|
||||
)) as Array<{ snapshot: Record<string, unknown> }>;
|
||||
if (!row) throw assetNotFound();
|
||||
return row.snapshot;
|
||||
}
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import {
|
||||
AssetAttributeDataType,
|
||||
AssetTypeOperationalRole,
|
||||
} from '../database/entities';
|
||||
|
||||
export interface MasterBootstrapAttributePreset {
|
||||
code: string;
|
||||
name: string;
|
||||
dataType: AssetAttributeDataType;
|
||||
isRequired: boolean;
|
||||
unit: string | null;
|
||||
options: string[] | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface MasterBootstrapTypePreset {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
canBeRoot: boolean;
|
||||
operationalRole: AssetTypeOperationalRole;
|
||||
allowedParentCodes: string[];
|
||||
attributes: MasterBootstrapAttributePreset[];
|
||||
}
|
||||
|
||||
const text = (code: string, name: string, sortOrder: number, unit: string | null = null): MasterBootstrapAttributePreset => ({
|
||||
code,
|
||||
name,
|
||||
dataType: AssetAttributeDataType.TEXT,
|
||||
isRequired: false,
|
||||
unit,
|
||||
options: null,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const number = (code: string, name: string, sortOrder: number, unit: string | null = null): MasterBootstrapAttributePreset => ({
|
||||
code,
|
||||
name,
|
||||
dataType: AssetAttributeDataType.NUMBER,
|
||||
isRequired: false,
|
||||
unit,
|
||||
options: null,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const date = (code: string, name: string, sortOrder: number): MasterBootstrapAttributePreset => ({
|
||||
code,
|
||||
name,
|
||||
dataType: AssetAttributeDataType.DATE,
|
||||
isRequired: false,
|
||||
unit: null,
|
||||
options: null,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const select = (code: string, name: string, options: string[], sortOrder: number): MasterBootstrapAttributePreset => ({
|
||||
code,
|
||||
name,
|
||||
dataType: AssetAttributeDataType.SELECT,
|
||||
isRequired: false,
|
||||
unit: null,
|
||||
options,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
export const MASTER_BOOTSTRAP_PRESET_CODE = 'mendoza-hidrocarburos-v2';
|
||||
export const MASTER_BOOTSTRAP_PRESET_NAME = 'Hidrocarburos · Mendoza';
|
||||
|
||||
export const MASTER_BOOTSTRAP_CORE_CODES = [
|
||||
'area',
|
||||
'empresa',
|
||||
'yacimiento',
|
||||
'instalacion',
|
||||
'estacion',
|
||||
'subestacion',
|
||||
'pozo',
|
||||
'equipo',
|
||||
'ducto',
|
||||
] as const;
|
||||
|
||||
export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [
|
||||
{
|
||||
code: 'area',
|
||||
name: 'Área',
|
||||
description: 'Área hidrocarburífera administrada como ancla territorial. Concesiones, permisos y titulares se registran por separado en la capa legal.',
|
||||
canBeRoot: true,
|
||||
operationalRole: AssetTypeOperationalRole.AREA,
|
||||
allowedParentCodes: [],
|
||||
attributes: [
|
||||
text('identificacion_oficial', 'Identificación oficial', 10),
|
||||
text('cuenca', 'Cuenca', 20),
|
||||
text('departamento', 'Departamento', 30),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'empresa',
|
||||
name: 'Organización',
|
||||
description: 'Entidad jurídica u organización administrada (empresa, UTE u otra figura). Su rol como operadora, titular o participante se registra mediante relaciones históricas.',
|
||||
canBeRoot: true,
|
||||
operationalRole: AssetTypeOperationalRole.COMPANY,
|
||||
allowedParentCodes: [],
|
||||
attributes: [
|
||||
text('cuit', 'CUIT', 10),
|
||||
text('razon_social', 'Razón social', 20),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'yacimiento',
|
||||
name: 'Yacimiento',
|
||||
description: 'Unidad territorial u operativa dentro de un Área. Es opcional porque la documentación también utiliza el campo combinado Área/Yacimiento.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area'],
|
||||
attributes: [text('identificacion_oficial', 'Identificación oficial', 10)],
|
||||
},
|
||||
{
|
||||
code: 'estructura_local',
|
||||
name: 'Estructura local (fuente)',
|
||||
description: 'Nodo estructural provisional que conserva la nomenclatura propia de cada operadora o Área/Yacimiento. No implica una clasificación física normalizada por DH hasta su revisión.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||
attributes: [
|
||||
text('nivel_fuente', 'Nivel informado por la fuente', 10),
|
||||
text('clasificacion_fuente', 'Clasificación local informada', 20),
|
||||
text('ruta_fuente', 'Ruta / nomenclatura de origen', 30),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'locacion',
|
||||
name: 'Locación',
|
||||
description: 'Sitio físico dentro de un área o yacimiento que puede agrupar pozos, instalaciones o equipos.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'instalacion',
|
||||
name: 'Instalación de superficie',
|
||||
description: 'Tipo genérico de instalación de proceso, tratamiento, almacenamiento o apoyo. Se conserva como alternativa cuando no exista un tipo técnico más específico.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||
attributes: [text('tipo_instalacion', 'Tipo de instalación', 10)],
|
||||
},
|
||||
{
|
||||
code: 'planta',
|
||||
name: 'Planta',
|
||||
description: 'Instalación de superficie administrada como unidad de proceso, tratamiento, entrega o almacenamiento.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||
attributes: [text('funcion_planta', 'Función / denominación operativa', 10)],
|
||||
},
|
||||
{
|
||||
code: 'bateria',
|
||||
name: 'Batería',
|
||||
description: 'Batería hidrocarburífera administrada como instalación y contenedor de sistemas/equipos.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'estacion',
|
||||
name: 'Estación',
|
||||
description: 'Estación operativa perteneciente a un área, yacimiento, locación o instalación.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'subestacion',
|
||||
name: 'Subestación',
|
||||
description: 'Subestación o unidad subordinada dentro de una instalación o estación.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'zona_bombas',
|
||||
name: 'Zona de bombas',
|
||||
description: 'Sector o conjunto físico donde se agrupan bombas. Se distingue de cada equipo Bomba individual.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'sistema_drenaje',
|
||||
name: 'Sistema de drenaje',
|
||||
description: 'Sistema de drenaje de una instalación. Puede contener piletas u otros elementos que requieran identidad propia.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'sistema_electrico_iluminacion',
|
||||
name: 'Sistema eléctrico / iluminación',
|
||||
description: 'Sistema eléctrico y de iluminación administrable cuando requiere historial e inspección propios.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'sistema_defensa_incendios',
|
||||
name: 'Defensa contra incendios',
|
||||
description: 'Sistema de defensa contra incendios, incluyendo red y equipos asociados cuando corresponda.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'cargadero_descargadero',
|
||||
name: 'Cargadero / descargadero de camiones',
|
||||
description: 'Instalación utilizada para carga o descarga de camiones, inspeccionable como unidad.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'pileta_api',
|
||||
name: 'Pileta API',
|
||||
description: 'Pileta API identificable dentro de una instalación o sistema de drenaje.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['sistema_drenaje', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'pozo',
|
||||
name: 'Pozo',
|
||||
description: 'Pozo hidrocarburífero. El método o función se administra como atributo para no duplicar el Maestro en varios tipos de pozo.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion'],
|
||||
attributes: [
|
||||
text('identificacion_oficial', 'Identificación oficial', 10),
|
||||
text('tipo_pozo', 'Tipo de pozo informado', 20),
|
||||
select('metodo_extraccion', 'Método / función operativa', [
|
||||
'Bombeo mecánico',
|
||||
'Bombeo electrosumergible',
|
||||
'Bombeo de cavidad progresiva (PCP)',
|
||||
'Surgente / productor de gas',
|
||||
'Inyector de agua',
|
||||
'Otro / a validar',
|
||||
], 30),
|
||||
number('profundidad', 'Profundidad', 40, 'm'),
|
||||
text('estado_operativo', 'Estado operativo informado', 50),
|
||||
date('ultima_intervencion', 'Última intervención informada', 60),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'equipo',
|
||||
name: 'Equipo',
|
||||
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'],
|
||||
attributes: [
|
||||
text('fabricante', 'Fabricante', 10),
|
||||
text('modelo', 'Modelo', 20),
|
||||
text('numero_serie', 'Número de serie', 30),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'tanque',
|
||||
name: 'Tanque',
|
||||
description: 'Tanque de almacenamiento o proceso administrado como equipo individual.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [
|
||||
number('capacidad_nominal', 'Capacidad nominal', 10, 'm³'),
|
||||
text('producto_servicio', 'Producto / servicio', 20),
|
||||
text('fabricante', 'Fabricante', 30),
|
||||
text('modelo', 'Modelo', 40),
|
||||
text('numero_serie', 'Número de serie', 50),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'separador',
|
||||
name: 'Separador',
|
||||
description: 'Separador de proceso administrado como equipo individual.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'bomba',
|
||||
name: 'Bomba',
|
||||
description: 'Bomba individual. Puede pertenecer a una zona de bombas, sistema contra incendios, pozo u otra instalación.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero', 'pozo'],
|
||||
attributes: [
|
||||
text('fabricante', 'Fabricante', 10),
|
||||
text('modelo', 'Modelo', 20),
|
||||
text('numero_serie', 'Número de serie', 30),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'caldera',
|
||||
name: 'Caldera',
|
||||
description: 'Caldera administrada como equipo individual con controles documentales y operativos propios.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'antorcha',
|
||||
name: 'Antorcha',
|
||||
description: 'Antorcha administrada como equipo o instalación individual.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'colector',
|
||||
name: 'Colector',
|
||||
description: 'Colector administrado como activo individual cuando requiere trazabilidad propia.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'filtro',
|
||||
name: 'Filtro',
|
||||
description: 'Filtro administrado como equipo individual.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'equipo_flotacion',
|
||||
name: 'Equipo de flotación',
|
||||
description: 'Equipo de flotación administrado como activo individual.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'fwko',
|
||||
name: 'FWKO',
|
||||
description: 'Free Water Knock Out administrado como equipo individual.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'tratador',
|
||||
name: 'Tratador',
|
||||
description: 'Tratador administrado como equipo individual.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'calentador',
|
||||
name: 'Calentador',
|
||||
description: 'Calentador administrado como equipo individual.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [],
|
||||
},
|
||||
{
|
||||
code: 'ducto',
|
||||
name: 'Ducto / Cañería',
|
||||
description: 'Tramo de ducto o cañería administrable e inspeccionable dentro de un área, yacimiento, locación o instalación.',
|
||||
canBeRoot: false,
|
||||
operationalRole: AssetTypeOperationalRole.GENERIC,
|
||||
allowedParentCodes: ['area', 'yacimiento', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion'],
|
||||
attributes: [
|
||||
text('servicio', 'Servicio / fluido', 10),
|
||||
text('diametro_nominal', 'Diámetro nominal', 20),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||
if (
|
||||
preset.operationalRole === AssetTypeOperationalRole.GENERIC
|
||||
&& !['yacimiento', 'locacion', 'estructura_local', 'pozo', 'ducto', 'colector'].includes(preset.code)
|
||||
&& !preset.allowedParentCodes.includes('estructura_local')
|
||||
) {
|
||||
preset.allowedParentCodes.push('estructura_local');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { AssetTypesController } from './asset-types.controller';
|
||||
import { AssetTypesService } from './asset-types.service';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
import {
|
||||
AssetGeometriesController,
|
||||
MapAssetsController,
|
||||
} from './asset-geometries.controller';
|
||||
import { AssetGeometriesService } from './asset-geometries.service';
|
||||
import { AssetHistoryController } from './asset-history.controller';
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
import { AssetMediaController } from './asset-media.controller';
|
||||
import { AssetMediaService } from './asset-media.service';
|
||||
import { AssetProvenanceController } from './asset-provenance.controller';
|
||||
import { AssetProvenanceService } from './asset-provenance.service';
|
||||
import { AssetTemporalController } from './asset-temporal.controller';
|
||||
import { AssetTemporalService } from './asset-temporal.service';
|
||||
import { AssetOperationalRelationsController } from './asset-operational-relations.controller';
|
||||
import { AssetOperationalRelationsService } from './asset-operational-relations.service';
|
||||
import { AssetRegistryController } from './asset-registry.controller';
|
||||
import { AssetRegistryService } from './asset-registry.service';
|
||||
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
||||
import { InventoryStructureController } from './inventory-structure.controller';
|
||||
import { InventoryStructureService } from './inventory-structure.service';
|
||||
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
|
||||
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
|
||||
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||
import { InventoryBrowserController } from './inventory-browser.controller';
|
||||
import { InventoryBrowserService } from './inventory-browser.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [
|
||||
AssetTypesController,
|
||||
AssetsController,
|
||||
InventoryStructureController,
|
||||
InventoryFamilyCatalogController,
|
||||
InventoryTechnicalValuesController,
|
||||
InventoryBrowserController,
|
||||
InventoryMergeController,
|
||||
FieldInventoryMergeController,
|
||||
AssetGeometriesController,
|
||||
MapAssetsController,
|
||||
AssetHistoryController,
|
||||
AssetMediaController,
|
||||
AssetProvenanceController,
|
||||
AssetTemporalController,
|
||||
AssetOperationalRelationsController,
|
||||
AssetRegistryController,
|
||||
],
|
||||
providers: [
|
||||
AssetTypesService,
|
||||
AssetsService,
|
||||
InventoryStructureService,
|
||||
InventoryFamilyCatalogService,
|
||||
InventoryTechnicalValuesService,
|
||||
InventoryFunctionService,
|
||||
InventoryBrowserService,
|
||||
InventoryMergeService,
|
||||
MergedInventoryDossierService,
|
||||
AssetGeometriesService,
|
||||
AssetHistoryService,
|
||||
AssetMediaService,
|
||||
AssetProvenanceService,
|
||||
AssetTemporalService,
|
||||
AssetOperationalRelationsService,
|
||||
AssetRegistryService,
|
||||
FieldDiscoveryInspectionLinkService,
|
||||
],
|
||||
exports: [
|
||||
AssetHistoryService,
|
||||
AssetsService,
|
||||
InventoryMergeService,
|
||||
MergedInventoryDossierService,
|
||||
AssetGeometriesService,
|
||||
AssetMediaService,
|
||||
FieldDiscoveryInspectionLinkService,
|
||||
],
|
||||
})
|
||||
export class AssetMasterModule {}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AssetMediaKind } from '../database/entities';
|
||||
|
||||
export const MAX_ASSET_MEDIA_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
export interface UploadedAssetFile {
|
||||
buffer: Buffer;
|
||||
originalname: string;
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface InspectedAssetFile {
|
||||
originalName: string;
|
||||
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'application/pdf';
|
||||
extension: '.jpg' | '.png' | '.webp' | '.pdf';
|
||||
}
|
||||
|
||||
function invalidFile(message: string): BadRequestException {
|
||||
return new BadRequestException({ code: 'INVALID_ASSET_FILE', message });
|
||||
}
|
||||
|
||||
function detectedType(buffer: Buffer): Omit<InspectedAssetFile, 'originalName'> | null {
|
||||
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return { mimeType: 'image/jpeg', extension: '.jpg' };
|
||||
}
|
||||
if (
|
||||
buffer.length >= 8
|
||||
&& buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
||||
) {
|
||||
return { mimeType: 'image/png', extension: '.png' };
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12
|
||||
&& buffer.subarray(0, 4).toString('ascii') === 'RIFF'
|
||||
&& buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return { mimeType: 'image/webp', extension: '.webp' };
|
||||
}
|
||||
if (buffer.length >= 5 && buffer.subarray(0, 5).toString('ascii') === '%PDF-') {
|
||||
return { mimeType: 'application/pdf', extension: '.pdf' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inspectAssetFile(
|
||||
file: UploadedAssetFile | undefined,
|
||||
kind: AssetMediaKind,
|
||||
): InspectedAssetFile {
|
||||
if (!file?.buffer || file.size <= 0 || file.buffer.length <= 0) {
|
||||
throw invalidFile('Debe seleccionar un archivo no vacío');
|
||||
}
|
||||
if (file.size > MAX_ASSET_MEDIA_BYTES || file.buffer.length > MAX_ASSET_MEDIA_BYTES) {
|
||||
throw invalidFile('El archivo supera el máximo permitido de 15 MB');
|
||||
}
|
||||
const detected = detectedType(file.buffer);
|
||||
if (!detected) {
|
||||
throw invalidFile('Sólo se permiten JPG, PNG, WebP y PDF válidos');
|
||||
}
|
||||
if (kind === AssetMediaKind.PHOTO && !detected.mimeType.startsWith('image/')) {
|
||||
throw invalidFile('Una fotografía debe ser JPG, PNG o WebP');
|
||||
}
|
||||
if (kind === AssetMediaKind.DOCUMENT && detected.mimeType !== 'application/pdf') {
|
||||
throw invalidFile('Un documento debe ser un archivo PDF');
|
||||
}
|
||||
const originalName = file.originalname
|
||||
.replace(/[\u0000-\u001f\u007f]/g, '')
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
if (!originalName) throw invalidFile('El nombre original del archivo no es válido');
|
||||
return { ...detected, originalName };
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../common/http/request-context';
|
||||
import { AssetMediaKind } from '../database/entities';
|
||||
import {
|
||||
MAX_ASSET_MEDIA_BYTES,
|
||||
type UploadedAssetFile,
|
||||
} from './asset-media-file';
|
||||
import { AssetMediaService } from './asset-media.service';
|
||||
import { CreateAssetMediaDto } from './dto/create-asset-media.dto';
|
||||
import { UpdateAssetMediaDto } from './dto/update-asset-media.dto';
|
||||
|
||||
@Controller()
|
||||
export class AssetMediaController {
|
||||
constructor(private readonly media: AssetMediaService) {}
|
||||
|
||||
@Get('assets/:assetId/media')
|
||||
@RequirePermissions('assets.read_media')
|
||||
list(
|
||||
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||
) {
|
||||
return this.media.list(assetId);
|
||||
}
|
||||
|
||||
@Post('assets/:assetId/media')
|
||||
@RequirePermissions('assets.manage_media')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_ASSET_MEDIA_BYTES, files: 1 },
|
||||
}))
|
||||
upload(
|
||||
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||
@Body() dto: CreateAssetMediaDto,
|
||||
@UploadedFile() file: UploadedAssetFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.media.upload(assetId, dto, file, principal, request);
|
||||
}
|
||||
|
||||
@Patch('asset-media/:mediaId')
|
||||
@RequirePermissions('assets.manage_media')
|
||||
update(
|
||||
@Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string,
|
||||
@Body() dto: UpdateAssetMediaDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.media.update(mediaId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Delete('asset-media/:mediaId')
|
||||
@RequirePermissions('assets.manage_media')
|
||||
remove(
|
||||
@Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.media.remove(mediaId, principal, request);
|
||||
}
|
||||
|
||||
@Get('asset-media/:mediaId/content')
|
||||
@RequirePermissions('assets.read_media')
|
||||
async content(
|
||||
@Param('mediaId', new ParseUUIDPipe({ version: '4' })) mediaId: string,
|
||||
@Query('download') download: string | undefined,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const { filePath, media } = await this.media.content(mediaId);
|
||||
const forceDownload = download === '1' || media.kind === AssetMediaKind.DOCUMENT;
|
||||
const disposition = forceDownload ? 'attachment' : 'inline';
|
||||
const fallbackName = media.originalName
|
||||
.replace(/[^\x20-\x7e]/g, '_')
|
||||
.replace(/["\\]/g, '_');
|
||||
response.setHeader('Content-Type', media.mimeType);
|
||||
response.setHeader('Content-Length', String(media.sizeBytes));
|
||||
response.setHeader('Content-Disposition', `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(media.originalName)}`);
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
await new Promise<void>((resolveSend, rejectSend) => {
|
||||
response.sendFile(filePath, (error) => {
|
||||
if (error) rejectSend(error);
|
||||
else resolveSend();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { isAbsolute, parse, resolve } from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../common/http/request-context';
|
||||
import {
|
||||
AssetMediaKind,
|
||||
AssetMediaSource,
|
||||
AssetVersionChangeType,
|
||||
AuditAction,
|
||||
} from '../database/entities';
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
import {
|
||||
inspectAssetFile,
|
||||
type UploadedAssetFile,
|
||||
} from './asset-media-file';
|
||||
import type { CreateAssetMediaDto } from './dto/create-asset-media.dto';
|
||||
import type { UpdateAssetMediaDto } from './dto/update-asset-media.dto';
|
||||
|
||||
export interface AssetMediaView {
|
||||
id: string;
|
||||
assetId: string;
|
||||
kind: AssetMediaKind;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
capturedAt: Date | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
accuracyM: number | null;
|
||||
source: AssetMediaSource;
|
||||
uploadedBy: string | null;
|
||||
uploadedByUsername: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface StoredAssetMedia extends AssetMediaView {
|
||||
storedName: string;
|
||||
}
|
||||
|
||||
function assetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_NOT_FOUND',
|
||||
message: 'Activo no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function mediaNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_MEDIA_NOT_FOUND',
|
||||
message: 'Archivo de activo no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function coordinateError(): BadRequestException {
|
||||
return new BadRequestException({
|
||||
code: 'INVALID_MEDIA_COORDINATES',
|
||||
message: 'Latitud y longitud deben informarse juntas',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetMediaService {
|
||||
private readonly storageRoot: string;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly history: AssetHistoryService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
const configured = config.get<string>('ASSET_MEDIA_ROOT') ?? '/app/storage/asset-media';
|
||||
if (!isAbsolute(configured)) {
|
||||
throw new Error('ASSET_MEDIA_ROOT must be an absolute path');
|
||||
}
|
||||
this.storageRoot = resolve(configured);
|
||||
if (this.storageRoot === parse(this.storageRoot).root) {
|
||||
throw new Error('ASSET_MEDIA_ROOT cannot be the filesystem root');
|
||||
}
|
||||
}
|
||||
|
||||
async list(assetId: string): Promise<{ data: AssetMediaView[] }> {
|
||||
await this.requireAsset(this.dataSource.manager, assetId, false);
|
||||
const rows = (await this.dataSource.query(
|
||||
`${this.mediaSelect()}
|
||||
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
|
||||
ORDER BY media.created_at DESC`,
|
||||
[assetId],
|
||||
)) as StoredAssetMedia[];
|
||||
return { data: rows.map(({ storedName: _storedName, ...media }) => media) };
|
||||
}
|
||||
|
||||
async upload(
|
||||
assetId: string,
|
||||
dto: CreateAssetMediaDto,
|
||||
file: UploadedAssetFile | undefined,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetMediaView> {
|
||||
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
|
||||
const inspected = inspectAssetFile(file, dto.kind);
|
||||
const id = randomUUID();
|
||||
const storedName = `${id}${inspected.extension}`;
|
||||
const filePath = resolve(this.storageRoot, storedName);
|
||||
const source = principal.transport === 'bearer'
|
||||
? AssetMediaSource.ANDROID
|
||||
: AssetMediaSource.WEB;
|
||||
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||
|
||||
await mkdir(this.storageRoot, { recursive: true, mode: 0o700 });
|
||||
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
|
||||
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
await this.requireAsset(manager, assetId, true);
|
||||
await manager.query(
|
||||
`INSERT INTO asset_media (
|
||||
id, asset_id, kind, original_name, stored_name, mime_type,
|
||||
size_bytes, sha256, title, description, captured_at,
|
||||
latitude, longitude, accuracy_m, source, uploaded_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16
|
||||
)`,
|
||||
[
|
||||
id,
|
||||
assetId,
|
||||
dto.kind,
|
||||
inspected.originalName,
|
||||
storedName,
|
||||
inspected.mimeType,
|
||||
file!.buffer.length,
|
||||
sha256,
|
||||
dto.title?.trim() || null,
|
||||
dto.description?.trim() || null,
|
||||
dto.capturedAt ? new Date(dto.capturedAt) : null,
|
||||
dto.latitude ?? null,
|
||||
dto.longitude ?? null,
|
||||
dto.accuracyM ?? null,
|
||||
source,
|
||||
principal.userId,
|
||||
],
|
||||
);
|
||||
const created = await this.loadActive(manager, id);
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
assetId,
|
||||
AssetVersionChangeType.MEDIA_UPLOADED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_MEDIA_UPLOADED,
|
||||
entityType: 'asset_media',
|
||||
entityId: id,
|
||||
afterData: this.auditView(created),
|
||||
metadata: { assetId, versionNumber },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
const { storedName: _storedName, ...view } = created;
|
||||
return view;
|
||||
});
|
||||
} catch (error) {
|
||||
await unlink(filePath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
mediaId: string,
|
||||
dto: UpdateAssetMediaDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetMediaView> {
|
||||
if (Object.keys(dto).length === 0) {
|
||||
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||
}
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const before = await this.loadActive(manager, mediaId, true);
|
||||
const latitude = dto.latitude === undefined ? before.latitude : dto.latitude;
|
||||
const longitude = dto.longitude === undefined ? before.longitude : dto.longitude;
|
||||
const accuracyM = dto.accuracyM === undefined ? before.accuracyM : dto.accuracyM;
|
||||
this.validateCoordinates(latitude, longitude, accuracyM);
|
||||
|
||||
await manager.query(
|
||||
`UPDATE asset_media SET
|
||||
title = $2,
|
||||
description = $3,
|
||||
captured_at = $4,
|
||||
latitude = $5,
|
||||
longitude = $6,
|
||||
accuracy_m = $7,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[
|
||||
mediaId,
|
||||
dto.title === undefined ? before.title : dto.title?.trim() || null,
|
||||
dto.description === undefined
|
||||
? before.description
|
||||
: dto.description?.trim() || null,
|
||||
dto.capturedAt === undefined
|
||||
? before.capturedAt
|
||||
: dto.capturedAt ? new Date(dto.capturedAt) : null,
|
||||
latitude,
|
||||
longitude,
|
||||
accuracyM,
|
||||
],
|
||||
);
|
||||
const updated = await this.loadActive(manager, mediaId);
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
updated.assetId,
|
||||
AssetVersionChangeType.MEDIA_UPDATED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_MEDIA_UPDATED,
|
||||
entityType: 'asset_media',
|
||||
entityId: mediaId,
|
||||
beforeData: this.auditView(before),
|
||||
afterData: this.auditView(updated),
|
||||
metadata: { assetId: updated.assetId, versionNumber },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
const { storedName: _storedName, ...view } = updated;
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
async remove(
|
||||
mediaId: string,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<{ status: 'removed' }> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const before = await this.loadActive(manager, mediaId, true);
|
||||
await manager.query(
|
||||
`UPDATE asset_media
|
||||
SET deleted_at = CURRENT_TIMESTAMP,
|
||||
deleted_by = $2,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[mediaId, principal.userId],
|
||||
);
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
before.assetId,
|
||||
AssetVersionChangeType.MEDIA_REMOVED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_MEDIA_REMOVED,
|
||||
entityType: 'asset_media',
|
||||
entityId: mediaId,
|
||||
beforeData: this.auditView(before),
|
||||
afterData: { active: false },
|
||||
metadata: { assetId: before.assetId, versionNumber, physicalFileRetained: true },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return { status: 'removed' };
|
||||
});
|
||||
}
|
||||
|
||||
async content(mediaId: string): Promise<{
|
||||
filePath: string;
|
||||
media: StoredAssetMedia;
|
||||
}> {
|
||||
const media = await this.loadActive(this.dataSource.manager, mediaId);
|
||||
const filePath = resolve(this.storageRoot, media.storedName);
|
||||
if (!filePath.startsWith(`${this.storageRoot}/`)) {
|
||||
throw new InternalServerErrorException({
|
||||
code: 'INVALID_MEDIA_STORAGE_PATH',
|
||||
message: 'Ruta de almacenamiento inválida',
|
||||
});
|
||||
}
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
if (!fileStat.isFile() || fileStat.size !== media.sizeBytes) throw new Error('size mismatch');
|
||||
} catch {
|
||||
throw new InternalServerErrorException({
|
||||
code: 'ASSET_MEDIA_FILE_MISSING',
|
||||
message: 'El archivo físico no está disponible',
|
||||
});
|
||||
}
|
||||
return { filePath, media };
|
||||
}
|
||||
|
||||
private validateCoordinates(
|
||||
latitude: number | null | undefined,
|
||||
longitude: number | null | undefined,
|
||||
accuracyM: number | null | undefined,
|
||||
): void {
|
||||
const hasLatitude = latitude !== null && latitude !== undefined;
|
||||
const hasLongitude = longitude !== null && longitude !== undefined;
|
||||
if (hasLatitude !== hasLongitude) throw coordinateError();
|
||||
if (accuracyM !== null && accuracyM !== undefined && !hasLatitude) {
|
||||
throw new BadRequestException({
|
||||
code: 'MEDIA_ACCURACY_WITHOUT_COORDINATES',
|
||||
message: 'La precisión GPS requiere latitud y longitud',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async requireAsset(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
lock: boolean,
|
||||
): Promise<void> {
|
||||
const suffix = lock ? ' FOR UPDATE' : '';
|
||||
const [row] = (await manager.query(
|
||||
`SELECT 1 FROM assets WHERE id = $1${suffix}`,
|
||||
[assetId],
|
||||
)) as unknown[];
|
||||
if (!row) throw assetNotFound();
|
||||
}
|
||||
|
||||
private async loadActive(
|
||||
manager: EntityManager,
|
||||
mediaId: string,
|
||||
lock = false,
|
||||
): Promise<StoredAssetMedia> {
|
||||
const [row] = (await manager.query(
|
||||
`${this.mediaSelect()}
|
||||
WHERE media.id = $1 AND media.deleted_at IS NULL
|
||||
${lock ? 'FOR UPDATE OF media' : ''}`,
|
||||
[mediaId],
|
||||
)) as StoredAssetMedia[];
|
||||
if (!row) throw mediaNotFound();
|
||||
return row;
|
||||
}
|
||||
|
||||
private mediaSelect(): string {
|
||||
return `SELECT
|
||||
media.id,
|
||||
media.asset_id AS "assetId",
|
||||
media.kind,
|
||||
media.original_name AS "originalName",
|
||||
media.stored_name AS "storedName",
|
||||
media.mime_type AS "mimeType",
|
||||
media.size_bytes::double precision AS "sizeBytes",
|
||||
media.sha256,
|
||||
media.title,
|
||||
media.description,
|
||||
media.captured_at AS "capturedAt",
|
||||
media.latitude::double precision AS latitude,
|
||||
media.longitude::double precision AS longitude,
|
||||
media.accuracy_m::double precision AS "accuracyM",
|
||||
media.source,
|
||||
media.uploaded_by AS "uploadedBy",
|
||||
uploader.username AS "uploadedByUsername",
|
||||
media.created_at AS "createdAt",
|
||||
media.updated_at AS "updatedAt"
|
||||
FROM asset_media media
|
||||
LEFT JOIN users uploader ON uploader.id = media.uploaded_by`;
|
||||
}
|
||||
|
||||
private auditView(media: StoredAssetMedia): Record<string, unknown> {
|
||||
return {
|
||||
id: media.id,
|
||||
assetId: media.assetId,
|
||||
kind: media.kind,
|
||||
originalName: media.originalName,
|
||||
mimeType: media.mimeType,
|
||||
sizeBytes: media.sizeBytes,
|
||||
sha256: media.sha256,
|
||||
title: media.title,
|
||||
description: media.description,
|
||||
capturedAt: media.capturedAt,
|
||||
latitude: media.latitude,
|
||||
longitude: media.longitude,
|
||||
accuracyM: media.accuracyM,
|
||||
source: media.source,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { AssetOperationalRelationsService } from './asset-operational-relations.service';
|
||||
import { CreateAreaCompanyRelationDto } from './dto/create-area-company-relation.dto';
|
||||
import { EndAreaCompanyRelationDto } from './dto/end-area-company-relation.dto';
|
||||
import { ListAreaCompanyRelationsQueryDto } from './dto/list-area-company-relations-query.dto';
|
||||
import { ListOperationalAreasQueryDto } from './dto/list-operational-areas-query.dto';
|
||||
|
||||
@Controller('asset-operational-relations')
|
||||
export class AssetOperationalRelationsController {
|
||||
constructor(private readonly relations: AssetOperationalRelationsService) {}
|
||||
|
||||
@Get('areas')
|
||||
@RequirePermissions('asset_relations.read')
|
||||
areas(@Query() query: ListOperationalAreasQueryDto) {
|
||||
return this.relations.listAreas(query.parentId);
|
||||
}
|
||||
|
||||
@Get('companies')
|
||||
@RequirePermissions('asset_relations.read')
|
||||
companies() {
|
||||
return this.relations.listCompanies();
|
||||
}
|
||||
|
||||
@Get('organizations')
|
||||
@RequirePermissions('asset_relations.read')
|
||||
organizations() {
|
||||
return this.relations.listCompanies();
|
||||
}
|
||||
|
||||
@Get('areas/:areaId/companies')
|
||||
@RequirePermissions('asset_relations.read')
|
||||
companiesForArea(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
|
||||
return this.relations.listCompaniesForArea(areaId);
|
||||
}
|
||||
|
||||
@Get('areas/:areaId/organizations')
|
||||
@RequirePermissions('asset_relations.read')
|
||||
organizationsForArea(@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string) {
|
||||
return this.relations.listCompaniesForArea(areaId);
|
||||
}
|
||||
|
||||
@Get('companies/:companyId/areas')
|
||||
@RequirePermissions('asset_relations.read')
|
||||
areasForCompany(@Param('companyId', new ParseUUIDPipe({ version: '4' })) companyId: string) {
|
||||
return this.relations.listAreasForCompany(companyId);
|
||||
}
|
||||
|
||||
@Get('organizations/:organizationId/areas')
|
||||
@RequirePermissions('asset_relations.read')
|
||||
areasForOrganization(@Param('organizationId', new ParseUUIDPipe({ version: '4' })) organizationId: string) {
|
||||
return this.relations.listAreasForCompany(organizationId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('asset_relations.read')
|
||||
list(@Query() query: ListAreaCompanyRelationsQueryDto) {
|
||||
return this.relations.list(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('asset_relations.manage')
|
||||
create(
|
||||
@Body() dto: CreateAreaCompanyRelationDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.relations.create(dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/end')
|
||||
@RequirePermissions('asset_relations.manage')
|
||||
end(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: EndAreaCompanyRelationDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.relations.end(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { AreaOrganizationRole, AssetTypeOperationalRole, AuditAction } from '../database/entities';
|
||||
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||
import type { CreateAreaCompanyRelationDto } from './dto/create-area-company-relation.dto';
|
||||
import type { EndAreaCompanyRelationDto } from './dto/end-area-company-relation.dto';
|
||||
import type { ListAreaCompanyRelationsQueryDto } from './dto/list-area-company-relations-query.dto';
|
||||
|
||||
export interface OperationalAssetSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
commonName: string | null;
|
||||
typeName: string;
|
||||
}
|
||||
|
||||
export interface AreaCompanyRelationView {
|
||||
id: string;
|
||||
area: OperationalAssetSummary;
|
||||
company: OperationalAssetSummary;
|
||||
relationRole: AreaOrganizationRole;
|
||||
participationPercent: number | null;
|
||||
legalInstrument: string | null;
|
||||
sourceDocumentId: string | null;
|
||||
validFrom: Date;
|
||||
validUntil: Date | null;
|
||||
startReason: string;
|
||||
endReason: string | null;
|
||||
createdBy: { id: string; username: string } | null;
|
||||
endedBy: { id: string; username: string } | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
active: boolean;
|
||||
assignedAssetCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetOperationalRelationsService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async listAreas(parentId?: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||
if (!parentId) {
|
||||
return { data: await this.listAssetsByRole(AssetTypeOperationalRole.AREA) };
|
||||
}
|
||||
const data = (await this.dataSource.query(`
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, parent_id FROM assets WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT parent.id, parent.parent_id
|
||||
FROM assets parent
|
||||
INNER JOIN ancestors current ON parent.id = current.parent_id
|
||||
)
|
||||
SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName"
|
||||
FROM ancestors
|
||||
INNER JOIN assets asset ON asset.id = ancestors.id
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
WHERE asset_type.operational_role = $2
|
||||
AND asset_type.is_active = true
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
ORDER BY asset.name, asset.code
|
||||
`, [parentId, AssetTypeOperationalRole.AREA])) as OperationalAssetSummary[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async listCompanies(): Promise<{ data: OperationalAssetSummary[] }> {
|
||||
return { data: await this.listAssetsByRole(AssetTypeOperationalRole.COMPANY) };
|
||||
}
|
||||
|
||||
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
|
||||
const data = (await this.dataSource.query(`
|
||||
SELECT DISTINCT company.id, company.code, company.name,
|
||||
company.common_name AS "commonName", company_type.name AS "typeName"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets company ON company.id = relation.company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
||||
WHERE relation.area_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND company.information_status <> 'INACTIVE'
|
||||
ORDER BY company.name, company.code
|
||||
`, [areaId])) as OperationalAssetSummary[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
|
||||
const data = (await this.dataSource.query(`
|
||||
SELECT DISTINCT area.id, area.code, area.name,
|
||||
area.common_name AS "commonName", area_type.name AS "typeName"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets area ON area.id = relation.area_id
|
||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||
WHERE relation.company_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND area.information_status <> 'INACTIVE'
|
||||
ORDER BY area.name, area.code
|
||||
`, [companyId])) as OperationalAssetSummary[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async list(query: ListAreaCompanyRelationsQueryDto): Promise<{ data: AreaCompanyRelationView[] }> {
|
||||
const conditions: string[] = [];
|
||||
const parameters: unknown[] = [];
|
||||
const add = (value: unknown): string => {
|
||||
parameters.push(value);
|
||||
return `$${parameters.length}`;
|
||||
};
|
||||
if (query.areaId) conditions.push(`relation.area_id = ${add(query.areaId)}`);
|
||||
if (query.companyId) conditions.push(`relation.company_id = ${add(query.companyId)}`);
|
||||
if (!query.includeHistory) conditions.push('relation.valid_until IS NULL');
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const data = (await this.dataSource.query(
|
||||
`${this.relationSelect(where)} ORDER BY relation.valid_until NULLS FIRST, relation.valid_from DESC`,
|
||||
parameters,
|
||||
)) as AreaCompanyRelationView[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateAreaCompanyRelationDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AreaCompanyRelationView> {
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
await this.requireAssetRole(manager, dto.areaId, AssetTypeOperationalRole.AREA);
|
||||
await this.requireAssetRole(manager, dto.companyId, AssetTypeOperationalRole.COMPANY);
|
||||
if (dto.sourceDocumentId) {
|
||||
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
|
||||
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
|
||||
}
|
||||
|
||||
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
|
||||
const [currentOperator] = (await manager.query(`
|
||||
SELECT relation.id,company.name AS "companyName"
|
||||
FROM area_company_relations relation
|
||||
JOIN assets company ON company.id=relation.company_id
|
||||
WHERE relation.area_id=$1
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND relation.company_id<>$2
|
||||
ORDER BY relation.valid_from DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE OF relation
|
||||
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
|
||||
if (currentOperator) {
|
||||
throw new ConflictException({
|
||||
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
|
||||
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = (await manager.query(`
|
||||
INSERT INTO area_company_relations (
|
||||
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
|
||||
|
||||
// F5/F6: changing the Area operator is a temporal relation event only.
|
||||
// Existing Inventory keeps its creation/historical operator snapshot and
|
||||
// physical hierarchy unchanged. Runtime ownership must resolve this row.
|
||||
const created = await this.loadRelation(manager, row.id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_AREA_COMPANY_RELATION_CREATED,
|
||||
entityType: 'area_company_relation',
|
||||
entityId: row.id,
|
||||
afterData: this.auditView(created),
|
||||
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
|
||||
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
|
||||
: undefined,
|
||||
}, manager);
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({
|
||||
code: 'AREA_COMPANY_RELATION_EXISTS',
|
||||
message: 'La organización ya tiene ese rol activo en el área',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async end(
|
||||
id: string,
|
||||
dto: EndAreaCompanyRelationDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AreaCompanyRelationView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const before = await this.loadRelation(manager, id, true);
|
||||
if (!before.active) {
|
||||
throw new ConflictException({
|
||||
code: 'AREA_COMPANY_RELATION_ALREADY_ENDED',
|
||||
message: 'La relación ya se encuentra finalizada',
|
||||
});
|
||||
}
|
||||
|
||||
// F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
|
||||
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
|
||||
await manager.query(`
|
||||
UPDATE area_company_relations
|
||||
SET valid_until = CURRENT_TIMESTAMP,
|
||||
end_reason = $2,
|
||||
ended_by = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND valid_until IS NULL
|
||||
`, [id, dto.reason, principal.userId]);
|
||||
const updated = await this.loadRelation(manager, id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_AREA_COMPANY_RELATION_ENDED,
|
||||
entityType: 'area_company_relation',
|
||||
entityId: id,
|
||||
beforeData: this.auditView(before),
|
||||
afterData: this.auditView(updated),
|
||||
metadata: {
|
||||
inventoryHierarchyChanged:false,
|
||||
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
|
||||
},
|
||||
}, manager);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
private async listAssetsByRole(role: AssetTypeOperationalRole): Promise<OperationalAssetSummary[]> {
|
||||
return (await this.dataSource.query(`
|
||||
SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset_type.name AS "typeName"
|
||||
FROM assets asset
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
WHERE asset_type.operational_role = $1
|
||||
AND asset_type.is_active = true
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
ORDER BY asset.name, asset.code
|
||||
`, [role])) as OperationalAssetSummary[];
|
||||
}
|
||||
|
||||
private async requireAssetRole(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
role: AssetTypeOperationalRole,
|
||||
): Promise<void> {
|
||||
const [row] = (await manager.query(`
|
||||
SELECT
|
||||
asset.id,
|
||||
asset.information_status AS "informationStatus",
|
||||
asset_type.operational_role AS role,
|
||||
asset_type.is_active AS "typeActive"
|
||||
FROM assets asset
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
WHERE asset.id = $1
|
||||
`, [assetId])) as Array<{
|
||||
id: string;
|
||||
informationStatus: string;
|
||||
role: AssetTypeOperationalRole;
|
||||
typeActive: boolean;
|
||||
}>;
|
||||
if (!row) {
|
||||
throw new BadRequestException({
|
||||
code: 'OPERATIONAL_ASSET_NOT_FOUND',
|
||||
message: role === AssetTypeOperationalRole.AREA ? 'El área seleccionada no existe' : 'La empresa seleccionada no existe',
|
||||
});
|
||||
}
|
||||
if (row.role !== role) {
|
||||
throw new BadRequestException({
|
||||
code: 'OPERATIONAL_ASSET_ROLE_INVALID',
|
||||
message: role === AssetTypeOperationalRole.AREA
|
||||
? 'El activo seleccionado no está configurado como Área'
|
||||
: 'El activo seleccionado no está configurado como Empresa',
|
||||
});
|
||||
}
|
||||
if (!row.typeActive || row.informationStatus === 'INACTIVE') {
|
||||
throw new ConflictException({
|
||||
code: 'OPERATIONAL_ASSET_INACTIVE',
|
||||
message: role === AssetTypeOperationalRole.AREA
|
||||
? 'El área o su tipo se encuentra inactivo'
|
||||
: 'La empresa o su tipo se encuentra inactivo',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRelation(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
lock = false,
|
||||
): Promise<AreaCompanyRelationView> {
|
||||
if (lock) {
|
||||
const rows = await manager.query(
|
||||
'SELECT id FROM area_company_relations WHERE id = $1 FOR UPDATE',
|
||||
[id],
|
||||
) as unknown[];
|
||||
if (rows.length === 0) throw this.relationNotFound();
|
||||
}
|
||||
const [row] = (await manager.query(
|
||||
`${this.relationSelect('WHERE relation.id = $1')}`,
|
||||
[id],
|
||||
)) as AreaCompanyRelationView[];
|
||||
if (!row) throw this.relationNotFound();
|
||||
return row;
|
||||
}
|
||||
|
||||
private relationSelect(where: string): string {
|
||||
return `
|
||||
SELECT
|
||||
relation.id,
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', area.id, 'code', area.code, 'name', area.name, 'commonName', area.common_name, 'typeName', area_type.name
|
||||
) AS area,
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', company.id, 'code', company.code, 'name', company.name, 'commonName', company.common_name, 'typeName', company_type.name
|
||||
) AS company,
|
||||
relation.relation_role AS "relationRole",
|
||||
relation.participation_percent::double precision AS "participationPercent",
|
||||
relation.legal_instrument AS "legalInstrument",
|
||||
relation.source_document_id AS "sourceDocumentId",
|
||||
relation.valid_from AS "validFrom",
|
||||
relation.valid_until AS "validUntil",
|
||||
relation.start_reason AS "startReason",
|
||||
relation.end_reason AS "endReason",
|
||||
CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', creator.id, 'username', creator.username
|
||||
) END AS "createdBy",
|
||||
CASE WHEN ender.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', ender.id, 'username', ender.username
|
||||
) END AS "endedBy",
|
||||
relation.created_at AS "createdAt",
|
||||
relation.updated_at AS "updatedAt",
|
||||
(relation.valid_until IS NULL) AS active,
|
||||
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
|
||||
WHERE asset.operational_area_id = relation.area_id
|
||||
AND asset.operator_company_id = relation.company_id
|
||||
AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets area ON area.id = relation.area_id
|
||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||
INNER JOIN assets company ON company.id = relation.company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
||||
LEFT JOIN users creator ON creator.id = relation.created_by
|
||||
LEFT JOIN users ender ON ender.id = relation.ended_by
|
||||
${where}
|
||||
`;
|
||||
}
|
||||
|
||||
private relationNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'AREA_COMPANY_RELATION_NOT_FOUND',
|
||||
message: 'Relación entre área y empresa no encontrada',
|
||||
});
|
||||
}
|
||||
|
||||
private auditView(relation: AreaCompanyRelationView): Record<string, unknown> {
|
||||
return {
|
||||
id: relation.id,
|
||||
areaId: relation.area.id,
|
||||
companyId: relation.company.id,
|
||||
relationRole: relation.relationRole,
|
||||
participationPercent: relation.participationPercent,
|
||||
legalInstrument: relation.legalInstrument,
|
||||
sourceDocumentId: relation.sourceDocumentId,
|
||||
validFrom: relation.validFrom,
|
||||
validUntil: relation.validUntil,
|
||||
startReason: relation.startReason,
|
||||
endReason: relation.endReason,
|
||||
active: relation.active,
|
||||
assignedAssetCount: relation.assignedAssetCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../common/http/request-context';
|
||||
import { AssetProvenanceService } from './asset-provenance.service';
|
||||
import { UpdateAssetProvenanceDto } from './dto/update-asset-provenance.dto';
|
||||
|
||||
@Controller('assets/:assetId/provenance')
|
||||
export class AssetProvenanceController {
|
||||
constructor(private readonly provenance: AssetProvenanceService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read_provenance')
|
||||
get(
|
||||
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||
) {
|
||||
return this.provenance.get(assetId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermissions('assets.manage_provenance')
|
||||
update(
|
||||
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||
@Body() dto: UpdateAssetProvenanceDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.provenance.update(assetId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('verify')
|
||||
@RequirePermissions('assets.verify_provenance')
|
||||
verify(
|
||||
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.provenance.verify(assetId, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type {
|
||||
AuthPrincipal,
|
||||
RequestWithContext,
|
||||
} from '../common/http/request-context';
|
||||
import {
|
||||
AssetDataOrigin,
|
||||
AssetVersionChangeType,
|
||||
AuditAction,
|
||||
} from '../database/entities';
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
import type { UpdateAssetProvenanceDto } from './dto/update-asset-provenance.dto';
|
||||
|
||||
export interface AssetProvenanceView {
|
||||
assetId: string;
|
||||
origin: AssetDataOrigin;
|
||||
sourceName: string | null;
|
||||
sourceReference: string | null;
|
||||
observedAt: Date | null;
|
||||
notes: string | null;
|
||||
verifiedAt: Date | null;
|
||||
verifiedBy: string | null;
|
||||
verifiedByUsername: string | null;
|
||||
updatedAt: Date;
|
||||
updatedBy: string | null;
|
||||
updatedByUsername: string | null;
|
||||
}
|
||||
|
||||
function assetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_NOT_FOUND',
|
||||
message: 'Activo no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetProvenanceService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly history: AssetHistoryService,
|
||||
) {}
|
||||
|
||||
get(assetId: string): Promise<AssetProvenanceView> {
|
||||
return this.load(this.dataSource.manager, assetId);
|
||||
}
|
||||
|
||||
async update(
|
||||
assetId: string,
|
||||
dto: UpdateAssetProvenanceDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetProvenanceView> {
|
||||
this.validateSource(dto);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const before = await this.load(manager, assetId, true);
|
||||
await manager.query(
|
||||
`UPDATE assets SET
|
||||
data_origin = $2,
|
||||
source_name = $3,
|
||||
source_reference = $4,
|
||||
source_observed_at = $5,
|
||||
source_notes = $6,
|
||||
provenance_verified_at = NULL,
|
||||
provenance_verified_by = NULL,
|
||||
provenance_updated_at = CURRENT_TIMESTAMP,
|
||||
provenance_updated_by = $7,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
updated_by = $7
|
||||
WHERE id = $1`,
|
||||
[
|
||||
assetId,
|
||||
dto.origin,
|
||||
dto.sourceName?.trim() || null,
|
||||
dto.sourceReference?.trim() || null,
|
||||
dto.observedAt ? new Date(dto.observedAt) : null,
|
||||
dto.notes?.trim() || null,
|
||||
principal.userId,
|
||||
],
|
||||
);
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
assetId,
|
||||
AssetVersionChangeType.PROVENANCE_UPDATED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
const updated = await this.load(manager, assetId);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_PROVENANCE_UPDATED,
|
||||
entityType: 'asset_provenance',
|
||||
entityId: assetId,
|
||||
beforeData: { ...before },
|
||||
afterData: { ...updated },
|
||||
metadata: { versionNumber, verificationCleared: before.verifiedAt !== null },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async verify(
|
||||
assetId: string,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetProvenanceView> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const before = await this.load(manager, assetId, true);
|
||||
if (before.verifiedAt) return before;
|
||||
await manager.query(
|
||||
`UPDATE assets SET
|
||||
provenance_verified_at = CURRENT_TIMESTAMP,
|
||||
provenance_verified_by = $2,
|
||||
provenance_updated_at = CURRENT_TIMESTAMP,
|
||||
provenance_updated_by = $2,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
updated_by = $2
|
||||
WHERE id = $1`,
|
||||
[assetId, principal.userId],
|
||||
);
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
assetId,
|
||||
AssetVersionChangeType.PROVENANCE_VERIFIED,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
const verified = await this.load(manager, assetId);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_PROVENANCE_VERIFIED,
|
||||
entityType: 'asset_provenance',
|
||||
entityId: assetId,
|
||||
beforeData: { ...before },
|
||||
afterData: { ...verified },
|
||||
metadata: { versionNumber },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return verified;
|
||||
});
|
||||
}
|
||||
|
||||
private validateSource(dto: UpdateAssetProvenanceDto): void {
|
||||
const requiresNamedSource = dto.origin === AssetDataOrigin.PROVIDED_DOCUMENT
|
||||
|| dto.origin === AssetDataOrigin.IMPORT;
|
||||
if (requiresNamedSource && !dto.sourceName?.trim()) {
|
||||
throw new BadRequestException({
|
||||
code: 'PROVENANCE_SOURCE_REQUIRED',
|
||||
message: 'La documentación recibida y las importaciones requieren identificar la fuente',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async load(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
lock = false,
|
||||
): Promise<AssetProvenanceView> {
|
||||
const [row] = (await manager.query(
|
||||
`SELECT
|
||||
asset.id AS "assetId",
|
||||
asset.data_origin AS origin,
|
||||
asset.source_name AS "sourceName",
|
||||
asset.source_reference AS "sourceReference",
|
||||
asset.source_observed_at AS "observedAt",
|
||||
asset.source_notes AS notes,
|
||||
asset.provenance_verified_at AS "verifiedAt",
|
||||
asset.provenance_verified_by AS "verifiedBy",
|
||||
verifier.username AS "verifiedByUsername",
|
||||
asset.provenance_updated_at AS "updatedAt",
|
||||
asset.provenance_updated_by AS "updatedBy",
|
||||
updater.username AS "updatedByUsername"
|
||||
FROM assets asset
|
||||
LEFT JOIN users verifier ON verifier.id = asset.provenance_verified_by
|
||||
LEFT JOIN users updater ON updater.id = asset.provenance_updated_by
|
||||
WHERE asset.id = $1
|
||||
${lock ? 'FOR UPDATE OF asset' : ''}`,
|
||||
[assetId],
|
||||
)) as AssetProvenanceView[];
|
||||
if (!row) throw assetNotFound();
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req } from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { AssetRegistryService } from './asset-registry.service';
|
||||
import { AddAreaLegalRightOrganizationDto } from './dto/add-area-legal-right-organization.dto';
|
||||
import { AddOrganizationMembershipDto } from './dto/add-organization-membership.dto';
|
||||
import { CreateAreaLegalRightDto } from './dto/create-area-legal-right.dto';
|
||||
import { CreateExternalIdentifierDto } from './dto/create-external-identifier.dto';
|
||||
import { CreateSourceDocumentDto } from './dto/create-source-document.dto';
|
||||
import { EndAreaLegalRightOrganizationDto } from './dto/end-area-legal-right-organization.dto';
|
||||
import { EndExternalIdentifierDto } from './dto/end-external-identifier.dto';
|
||||
import { EndOrganizationMembershipDto } from './dto/end-organization-membership.dto';
|
||||
import { LinkAssetSourceDocumentDto } from './dto/link-asset-source-document.dto';
|
||||
import { UpdateAreaLegalRightDto } from './dto/update-area-legal-right.dto';
|
||||
import { UpsertOrganizationProfileDto } from './dto/upsert-organization-profile.dto';
|
||||
|
||||
@Controller()
|
||||
export class AssetRegistryController {
|
||||
constructor(private readonly registry: AssetRegistryService) {}
|
||||
|
||||
@Get('assets/:assetId/registry') @RequirePermissions('asset_registry.read')
|
||||
get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string){ return this.registry.get(assetId); }
|
||||
|
||||
@Get('source-documents') @RequirePermissions('asset_registry.read')
|
||||
documents(@Query('search') search?:string){ return this.registry.listSourceDocuments(search); }
|
||||
|
||||
@Post('source-documents') @RequirePermissions('asset_registry.manage')
|
||||
createDocument(@Body() dto:CreateSourceDocumentDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){ return this.registry.createSourceDocument(dto,principal,request); }
|
||||
|
||||
@Patch('assets/:assetId/organization-profile') @RequirePermissions('asset_registry.manage')
|
||||
profile(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Body() dto:UpsertOrganizationProfileDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){ return this.registry.upsertOrganizationProfile(assetId,dto,principal,request); }
|
||||
|
||||
@Post('organizations/:organizationId/memberships') @RequirePermissions('asset_registry.manage')
|
||||
addMembership(@Param('organizationId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:AddOrganizationMembershipDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addOrganizationMembership(id,dto,p,r); }
|
||||
|
||||
@Post('organization-memberships/:id/end') @RequirePermissions('asset_registry.manage')
|
||||
endMembership(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndOrganizationMembershipDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endOrganizationMembership(id,dto,p,r); }
|
||||
|
||||
@Post('assets/:assetId/source-documents/:documentId') @RequirePermissions('asset_registry.manage')
|
||||
link(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Param('documentId',new ParseUUIDPipe({version:'4'})) documentId:string,@Body() dto:LinkAssetSourceDocumentDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.linkDocument(assetId,documentId,dto,p,r); }
|
||||
|
||||
@Post('assets/:assetId/external-identifiers') @RequirePermissions('asset_registry.manage')
|
||||
identifier(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,@Body() dto:CreateExternalIdentifierDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addExternalIdentifier(assetId,dto,p,r); }
|
||||
|
||||
@Post('asset-external-identifiers/:id/end') @RequirePermissions('asset_registry.manage')
|
||||
endIdentifier(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndExternalIdentifierDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endExternalIdentifier(id,dto,p,r); }
|
||||
|
||||
@Post('areas/:areaId/legal-rights') @RequirePermissions('asset_registry.manage')
|
||||
legalRight(@Param('areaId',new ParseUUIDPipe({version:'4'})) areaId:string,@Body() dto:CreateAreaLegalRightDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.createLegalRight(areaId,dto,p,r); }
|
||||
|
||||
@Patch('area-legal-rights/:rightId') @RequirePermissions('asset_registry.manage')
|
||||
updateRight(@Param('rightId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:UpdateAreaLegalRightDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.updateLegalRight(id,dto,p,r); }
|
||||
|
||||
@Post('area-legal-rights/:rightId/organizations') @RequirePermissions('asset_registry.manage')
|
||||
rightOrg(@Param('rightId',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:AddAreaLegalRightOrganizationDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.addLegalRightOrganization(id,dto,p,r); }
|
||||
|
||||
@Post('area-legal-right-organizations/:id/end') @RequirePermissions('asset_registry.manage')
|
||||
endRightOrg(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@Body() dto:EndAreaLegalRightOrganizationDto,@CurrentAuth() p:AuthPrincipal,@Req() r:RequestWithContext){ return this.registry.endLegalRightOrganization(id,dto,p,r); }
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import {
|
||||
AreaLegalRight,
|
||||
AreaLegalRightOrganization,
|
||||
Asset,
|
||||
AssetExternalIdentifier,
|
||||
AssetSourceDocument,
|
||||
AssetTypeOperationalRole,
|
||||
AssetVersionChangeType,
|
||||
AuditAction,
|
||||
OrganizationKind,
|
||||
OrganizationMembership,
|
||||
OrganizationProfile,
|
||||
SourceDocument,
|
||||
} from '../database/entities';
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
import type { AddAreaLegalRightOrganizationDto } from './dto/add-area-legal-right-organization.dto';
|
||||
import type { AddOrganizationMembershipDto } from './dto/add-organization-membership.dto';
|
||||
import type { CreateAreaLegalRightDto } from './dto/create-area-legal-right.dto';
|
||||
import type { CreateExternalIdentifierDto } from './dto/create-external-identifier.dto';
|
||||
import type { CreateSourceDocumentDto } from './dto/create-source-document.dto';
|
||||
import type { EndAreaLegalRightOrganizationDto } from './dto/end-area-legal-right-organization.dto';
|
||||
import type { EndExternalIdentifierDto } from './dto/end-external-identifier.dto';
|
||||
import type { EndOrganizationMembershipDto } from './dto/end-organization-membership.dto';
|
||||
import type { LinkAssetSourceDocumentDto } from './dto/link-asset-source-document.dto';
|
||||
import type { UpdateAreaLegalRightDto } from './dto/update-area-legal-right.dto';
|
||||
import type { UpsertOrganizationProfileDto } from './dto/upsert-organization-profile.dto';
|
||||
|
||||
function registryNotFound(entity: string): NotFoundException {
|
||||
return new NotFoundException({ code: 'ASSET_REGISTRY_NOT_FOUND', message: `${entity} no encontrado` });
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetRegistryService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly history: AssetHistoryService,
|
||||
) {}
|
||||
|
||||
async get(assetId: string) {
|
||||
await this.requireAsset(this.dataSource.manager, assetId);
|
||||
const [organizationProfile] = await this.dataSource.query(`
|
||||
SELECT asset_id AS "assetId", organization_kind AS "organizationKind", legal_name AS "legalName",
|
||||
tax_id AS "taxId", notification_email AS "notificationEmail", notes, created_at AS "createdAt", updated_at AS "updatedAt", updated_by AS "updatedBy"
|
||||
FROM organization_profiles WHERE asset_id=$1
|
||||
`, [assetId]);
|
||||
const organizationMemberships = await this.dataSource.query(`
|
||||
SELECT m.id,
|
||||
JSONB_BUILD_OBJECT('id',p.id,'code',p.code,'name',p.name) AS parent,
|
||||
JSONB_BUILD_OBJECT('id',member.id,'code',member.code,'name',member.name) AS member,
|
||||
m.role, m.participation_percent::double precision AS "participationPercent",
|
||||
m.valid_from AS "validFrom", m.valid_until AS "validUntil", m.source_document_id AS "sourceDocumentId",
|
||||
m.notes, m.end_reason AS "endReason", m.created_at AS "createdAt", m.updated_at AS "updatedAt"
|
||||
FROM organization_memberships m
|
||||
JOIN assets p ON p.id=m.parent_organization_id
|
||||
JOIN assets member ON member.id=m.member_organization_id
|
||||
WHERE m.parent_organization_id=$1 OR m.member_organization_id=$1
|
||||
ORDER BY m.valid_until NULLS FIRST, m.valid_from DESC
|
||||
`, [assetId]);
|
||||
const externalIdentifiers = await this.dataSource.query(`
|
||||
SELECT id, namespace, value, valid_from AS "validFrom", valid_until AS "validUntil",
|
||||
source_document_id AS "sourceDocumentId", notes, end_reason AS "endReason", created_at AS "createdAt"
|
||||
FROM asset_external_identifiers WHERE asset_id=$1 ORDER BY valid_until NULLS FIRST, namespace, valid_from DESC
|
||||
`, [assetId]);
|
||||
const sourceDocuments = await this.dataSource.query(`
|
||||
SELECT link.id AS "linkId", link.relation_type AS "relationType", link.notes AS "linkNotes",
|
||||
doc.id, doc.document_type AS "documentType", doc.document_number AS "documentNumber", doc.title,
|
||||
doc.issuer, doc.document_date AS "documentDate", doc.external_reference AS "externalReference", doc.notes
|
||||
FROM asset_source_documents link JOIN source_documents doc ON doc.id=link.document_id
|
||||
WHERE link.asset_id=$1 ORDER BY doc.document_date DESC NULLS LAST, doc.created_at DESC
|
||||
`, [assetId]);
|
||||
const legalRights = await this.dataSource.query(`
|
||||
SELECT r.id, r.right_type AS "rightType", r.name, r.instrument_number AS "instrumentNumber",
|
||||
r.valid_from AS "validFrom", r.valid_until AS "validUntil", r.status,
|
||||
r.source_document_id AS "sourceDocumentId", r.notes,
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id',o.id,'organizationId',o.organization_id,'organizationName',a.name,'role',o.role,
|
||||
'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,
|
||||
'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason
|
||||
) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC)
|
||||
FROM area_legal_right_organizations o JOIN assets a ON a.id=o.organization_id WHERE o.right_id=r.id),'[]'::jsonb) AS organizations
|
||||
FROM area_legal_rights r WHERE r.area_id=$1 ORDER BY r.valid_until DESC NULLS FIRST, r.valid_from DESC NULLS LAST
|
||||
`, [assetId]);
|
||||
return { organizationProfile: organizationProfile ?? null, organizationMemberships, externalIdentifiers, sourceDocuments, legalRights };
|
||||
}
|
||||
|
||||
async listSourceDocuments(search?: string) {
|
||||
const q = search?.trim();
|
||||
const params: unknown[] = [];
|
||||
const where = q ? `WHERE title ILIKE $1 OR document_number ILIKE $1 OR issuer ILIKE $1` : '';
|
||||
if (q) params.push(`%${q}%`);
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT id, document_type AS "documentType", document_number AS "documentNumber", title, issuer,
|
||||
document_date AS "documentDate", external_reference AS "externalReference", notes,
|
||||
created_at AS "createdAt", updated_at AS "updatedAt"
|
||||
FROM source_documents ${where}
|
||||
ORDER BY document_date DESC NULLS LAST, created_at DESC LIMIT 100
|
||||
`, params);
|
||||
return { data };
|
||||
}
|
||||
|
||||
async createSourceDocument(dto: CreateSourceDocumentDto, principal: AuthPrincipal, request: RequestWithContext) {
|
||||
try {
|
||||
return await this.dataSource.transaction(async manager => {
|
||||
const doc = manager.getRepository(SourceDocument).create({
|
||||
documentType: dto.documentType,
|
||||
documentNumber: dto.documentNumber ?? null,
|
||||
title: dto.title,
|
||||
issuer: dto.issuer ?? null,
|
||||
documentDate: dto.documentDate ?? null,
|
||||
externalReference: dto.externalReference ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
});
|
||||
await manager.getRepository(SourceDocument).save(doc);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request), action: AuditAction.SOURCE_DOCUMENT_CREATED,
|
||||
entityType: 'source_document', entityId: doc.id, afterData: { ...doc },
|
||||
}, manager);
|
||||
return doc;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new ConflictException({ code:'SOURCE_DOCUMENT_EXISTS', message:'Ya existe un documento con ese número y emisor' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async upsertOrganizationProfile(assetId: string, dto: UpsertOrganizationProfileDto, principal: AuthPrincipal, request: RequestWithContext) {
|
||||
try {
|
||||
return await this.dataSource.transaction(async manager => {
|
||||
const asset = await this.requireRole(manager, assetId, AssetTypeOperationalRole.COMPANY);
|
||||
const repo = manager.getRepository(OrganizationProfile);
|
||||
const before = await repo.findOne({ where: { assetId } });
|
||||
if (before && before.organizationKind !== dto.organizationKind) {
|
||||
const [usage] = await manager.query(`SELECT 1 FROM organization_memberships WHERE (parent_organization_id=$1 OR member_organization_id=$1) AND valid_until IS NULL LIMIT 1`, [assetId]);
|
||||
if (usage) throw new ConflictException({ code:'ORGANIZATION_KIND_IN_USE', message:'No se puede cambiar el tipo de organización mientras tenga una composición UTE activa' });
|
||||
}
|
||||
const profile = repo.create({ ...(before ?? {}), assetId, organizationKind:dto.organizationKind, legalName:dto.legalName ?? asset.name, taxId:dto.taxId ?? null, notificationEmail:dto.notificationEmail ?? null, notes:dto.notes ?? null, updatedBy:principal.userId });
|
||||
await repo.save(profile);
|
||||
const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.REGISTRY_UPDATED, principal, request);
|
||||
await this.audit.record({ ...administrationAuditContext(principal,request), action:AuditAction.ASSET_REGISTRY_UPDATED, entityType:'organization_profile', entityId:assetId, beforeData:before ? { ...before }:null, afterData:{ ...profile }, metadata:{versionNumber} }, manager);
|
||||
return profile;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new ConflictException({ code:'ORGANIZATION_TAX_ID_EXISTS', message:'El identificador fiscal ya pertenece a otra organización' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async addOrganizationMembership(parentId:string, dto:AddOrganizationMembershipDto, principal:AuthPrincipal, request:RequestWithContext) {
|
||||
try {
|
||||
return await this.dataSource.transaction(async manager => {
|
||||
await this.requireRole(manager,parentId,AssetTypeOperationalRole.COMPANY);
|
||||
await manager.query('SELECT id FROM assets WHERE id=$1 FOR UPDATE',[parentId]);
|
||||
await this.requireRole(manager,dto.memberOrganizationId,AssetTypeOperationalRole.COMPANY);
|
||||
const parentProfile = await manager.getRepository(OrganizationProfile).findOne({where:{assetId:parentId}});
|
||||
const memberProfile = await manager.getRepository(OrganizationProfile).findOne({where:{assetId:dto.memberOrganizationId}});
|
||||
if (parentProfile?.organizationKind !== OrganizationKind.UTE) throw new BadRequestException({code:'UTE_REQUIRED',message:'La organización contenedora debe estar configurada como UTE'});
|
||||
if (memberProfile?.organizationKind === OrganizationKind.UTE) throw new BadRequestException({code:'UTE_MEMBER_INVALID',message:'Una UTE no puede ser miembro directo de otra UTE'});
|
||||
if (dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId);
|
||||
const validFrom = dto.validFrom ?? this.today();
|
||||
const [overlap] = await manager.query(`
|
||||
SELECT 1 FROM organization_memberships
|
||||
WHERE parent_organization_id=$1 AND member_organization_id=$2 AND role=$3
|
||||
AND daterange(valid_from,COALESCE(valid_until,'infinity'::date),'[]') && daterange($4::date,'infinity'::date,'[]')
|
||||
LIMIT 1
|
||||
`,[parentId,dto.memberOrganizationId,dto.role,validFrom]);
|
||||
if (overlap) throw new ConflictException({code:'UTE_MEMBERSHIP_OVERLAP',message:'La participación se superpone con una vigencia histórica existente'});
|
||||
if (dto.participationPercent != null) {
|
||||
const [sum] = await manager.query(`SELECT COALESCE(SUM(participation_percent),0)::double precision AS total FROM organization_memberships WHERE parent_organization_id=$1 AND valid_until IS NULL`,[parentId]);
|
||||
if (Number(sum?.total ?? 0) + dto.participationPercent > 100.0001) throw new BadRequestException({code:'UTE_PARTICIPATION_EXCEEDS_100',message:'La participación activa de la UTE no puede superar el 100%'});
|
||||
}
|
||||
const repo=manager.getRepository(OrganizationMembership);
|
||||
const membership=repo.create({parentOrganizationId:parentId,memberOrganizationId:dto.memberOrganizationId,role:dto.role,participationPercent:dto.participationPercent == null ? null:String(dto.participationPercent),validFrom,validUntil:null,sourceDocumentId:dto.sourceDocumentId ?? null,notes:dto.notes ?? null,endReason:null,createdBy:principal.userId,endedBy:null});
|
||||
await repo.save(membership);
|
||||
await this.captureAssets(manager,[parentId,dto.memberOrganizationId],principal,request);
|
||||
await this.audit.record({ ...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'organization_membership',entityId:membership.id,afterData:{...membership}},manager);
|
||||
return membership;
|
||||
});
|
||||
} catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'UTE_MEMBERSHIP_EXISTS',message:'La participación ya se encuentra activa'}); throw error; }
|
||||
}
|
||||
|
||||
async endOrganizationMembership(id:string,dto:EndOrganizationMembershipDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||
return this.dataSource.transaction(async manager=>{
|
||||
const repo=manager.getRepository(OrganizationMembership);
|
||||
const membership=await repo.createQueryBuilder('m').where('m.id=:id',{id}).setLock('pessimistic_write').getOne();
|
||||
if(!membership) throw registryNotFound('Participación de organización');
|
||||
if(membership.validUntil) throw new ConflictException({code:'UTE_MEMBERSHIP_ENDED',message:'La participación ya está finalizada'});
|
||||
const end=dto.validUntil ?? this.today(); this.validateEndDate(end,membership.validFrom);
|
||||
membership.validUntil=end; membership.endReason=dto.reason; membership.endedBy=principal.userId; await repo.save(membership);
|
||||
await this.captureAssets(manager,[membership.parentOrganizationId,membership.memberOrganizationId],principal,request);
|
||||
await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'organization_membership',entityId:id,afterData:{...membership}},manager);
|
||||
return membership;
|
||||
});
|
||||
}
|
||||
|
||||
async linkDocument(assetId:string,documentId:string,dto:LinkAssetSourceDocumentDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||
try { return await this.dataSource.transaction(async manager=>{
|
||||
await this.requireAsset(manager,assetId); await this.requireDocument(manager,documentId);
|
||||
const repo=manager.getRepository(AssetSourceDocument); const link=repo.create({assetId,documentId,relationType:dto.relationType,notes:dto.notes??null,createdBy:principal.userId}); await repo.save(link);
|
||||
const versionNumber=await this.history.capture(manager,assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request);
|
||||
await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_source_document',entityId:link.id,afterData:{...link},metadata:{versionNumber}},manager); return link;
|
||||
}); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'ASSET_DOCUMENT_LINK_EXISTS',message:'El documento ya está vinculado al activo con ese tipo de relación'}); throw error; }
|
||||
}
|
||||
|
||||
async addExternalIdentifier(assetId:string,dto:CreateExternalIdentifierDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||
try { return await this.dataSource.transaction(async manager=>{
|
||||
await this.requireAsset(manager,assetId); if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId);
|
||||
const repo=manager.getRepository(AssetExternalIdentifier); const identifier=repo.create({assetId,namespace:dto.namespace,value:dto.value,validFrom:dto.validFrom ? new Date(dto.validFrom):new Date(),validUntil:null,sourceDocumentId:dto.sourceDocumentId??null,notes:dto.notes??null,endReason:null,createdBy:principal.userId,endedBy:null}); await repo.save(identifier);
|
||||
const versionNumber=await this.history.capture(manager,assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_external_identifier',entityId:identifier.id,afterData:{...identifier},metadata:{versionNumber}},manager); return identifier;
|
||||
}); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'EXTERNAL_IDENTIFIER_EXISTS',message:'Ese identificador externo ya está activo'}); throw error; }
|
||||
}
|
||||
|
||||
async endExternalIdentifier(id:string,dto:EndExternalIdentifierDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||
return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AssetExternalIdentifier); const item=await repo.createQueryBuilder('i').where('i.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!item) throw registryNotFound('Identificador'); if(item.validUntil) throw new ConflictException({code:'EXTERNAL_IDENTIFIER_ENDED',message:'El identificador ya está finalizado'}); item.validUntil=new Date(); item.endReason=dto.reason; item.endedBy=principal.userId; await repo.save(item); const versionNumber=await this.history.capture(manager,item.assetId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'asset_external_identifier',entityId:id,afterData:{...item},metadata:{versionNumber}},manager); return item; });
|
||||
}
|
||||
|
||||
async createLegalRight(areaId:string,dto:CreateAreaLegalRightDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||
return this.dataSource.transaction(async manager=>{ await this.requireRole(manager,areaId,AssetTypeOperationalRole.AREA); if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); this.validateDateRange(dto.validFrom??null,dto.validUntil??null); const repo=manager.getRepository(AreaLegalRight); const right=repo.create({areaId,rightType:dto.rightType,name:dto.name,instrumentNumber:dto.instrumentNumber??null,validFrom:dto.validFrom??null,validUntil:dto.validUntil??null,status:dto.status,sourceDocumentId:dto.sourceDocumentId??null,notes:dto.notes??null,createdBy:principal.userId,updatedBy:principal.userId}); await repo.save(right); const versionNumber=await this.history.capture(manager,areaId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_CREATED,entityType:'area_legal_right',entityId:right.id,afterData:{...right},metadata:{versionNumber}},manager); return right; });
|
||||
}
|
||||
|
||||
async updateLegalRight(id:string,dto:UpdateAreaLegalRightDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||
return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AreaLegalRight); const right=await repo.createQueryBuilder('r').where('r.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!right) throw registryNotFound('Derecho hidrocarburífero'); const before={...right}; if(dto.sourceDocumentId) await this.requireDocument(manager,dto.sourceDocumentId); const validFrom=dto.validFrom===undefined?right.validFrom:dto.validFrom; const validUntil=dto.validUntil===undefined?right.validUntil:dto.validUntil; this.validateDateRange(validFrom,validUntil); if(dto.name!==undefined)right.name=dto.name;if(dto.instrumentNumber!==undefined)right.instrumentNumber=dto.instrumentNumber;if(dto.validFrom!==undefined)right.validFrom=dto.validFrom;if(dto.validUntil!==undefined)right.validUntil=dto.validUntil;if(dto.status!==undefined)right.status=dto.status;if(dto.sourceDocumentId!==undefined)right.sourceDocumentId=dto.sourceDocumentId;if(dto.notes!==undefined)right.notes=dto.notes;right.updatedBy=principal.userId;await repo.save(right);const versionNumber=await this.history.capture(manager,right.areaId,AssetVersionChangeType.REGISTRY_UPDATED,principal,request);await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_UPDATED,entityType:'area_legal_right',entityId:id,beforeData:before,afterData:{...right},metadata:{reason:dto.reason,versionNumber}},manager);return right; });
|
||||
}
|
||||
|
||||
async addLegalRightOrganization(rightId:string,dto:AddAreaLegalRightOrganizationDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||
try { return await this.dataSource.transaction(async manager=>{ const right=await manager.getRepository(AreaLegalRight).createQueryBuilder('r').where('r.id=:rightId',{rightId}).setLock('pessimistic_write').getOne(); if(!right) throw registryNotFound('Derecho hidrocarburífero'); await this.requireRole(manager,dto.organizationId,AssetTypeOperationalRole.COMPANY); const validFrom=dto.validFrom??this.today(); const [overlap]=await manager.query(`SELECT 1 FROM area_legal_right_organizations WHERE right_id=$1 AND organization_id=$2 AND role=$3 AND daterange(valid_from,COALESCE(valid_until,'infinity'::date),'[]') && daterange($4::date,'infinity'::date,'[]') LIMIT 1`,[rightId,dto.organizationId,dto.role,validFrom]); if(overlap) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_OVERLAP',message:'La participación se superpone con una vigencia histórica existente'}); const repo=manager.getRepository(AreaLegalRightOrganization); const item=repo.create({rightId,organizationId:dto.organizationId,role:dto.role,participationPercent:dto.participationPercent==null?null:String(dto.participationPercent),validFrom,validUntil:null,notes:dto.notes??null,endReason:null,createdBy:principal.userId,endedBy:null}); await repo.save(item); await this.captureAssets(manager,[right.areaId,dto.organizationId],principal,request); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.ASSET_REGISTRY_UPDATED,entityType:'area_legal_right_organization',entityId:item.id,afterData:{...item}},manager); return item; }); } catch(error){ if(isUniqueViolation(error)) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_EXISTS',message:'La organización ya tiene ese rol activo en el derecho'}); throw error; }
|
||||
}
|
||||
|
||||
async endLegalRightOrganization(id:string,dto:EndAreaLegalRightOrganizationDto,principal:AuthPrincipal,request:RequestWithContext){
|
||||
return this.dataSource.transaction(async manager=>{ const repo=manager.getRepository(AreaLegalRightOrganization); const item=await repo.createQueryBuilder('o').where('o.id=:id',{id}).setLock('pessimistic_write').getOne(); if(!item) throw registryNotFound('Participación legal'); if(item.validUntil) throw new ConflictException({code:'LEGAL_RIGHT_ORGANIZATION_ENDED',message:'La participación ya está finalizada'}); const end=dto.validUntil??this.today();this.validateEndDate(end,item.validFrom);item.validUntil=end;item.endReason=dto.reason;item.endedBy=principal.userId;await repo.save(item);const right=await manager.getRepository(AreaLegalRight).findOne({where:{id:item.rightId}});if(right)await this.captureAssets(manager,[right.areaId,item.organizationId],principal,request);await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.AREA_LEGAL_RIGHT_ORGANIZATION_ENDED,entityType:'area_legal_right_organization',entityId:id,afterData:{...item}},manager);return item; });
|
||||
}
|
||||
|
||||
private async captureAssets(manager:EntityManager,ids:string[],principal:AuthPrincipal,request:RequestWithContext){ for(const id of [...new Set(ids)]) await this.history.capture(manager,id,AssetVersionChangeType.REGISTRY_UPDATED,principal,request); }
|
||||
private async requireAsset(manager:EntityManager,id:string):Promise<Asset>{ const asset=await manager.getRepository(Asset).findOne({where:{id}}); if(!asset) throw registryNotFound('Activo'); return asset; }
|
||||
private async requireRole(manager:EntityManager,id:string,role:AssetTypeOperationalRole):Promise<Asset>{ const [row]=await manager.query(`SELECT a.id FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=$1 AND t.operational_role=$2`,[id,role]); if(!row) throw new BadRequestException({code:'ASSET_ROLE_INVALID',message:role===AssetTypeOperationalRole.AREA?'El activo debe ser un Área':'El activo debe ser una Organización'}); return this.requireAsset(manager,id); }
|
||||
private async requireDocument(manager:EntityManager,id:string){ const doc=await manager.getRepository(SourceDocument).findOne({where:{id}}); if(!doc) throw registryNotFound('Documento fuente'); return doc; }
|
||||
private today(){ return new Date().toISOString().slice(0,10); }
|
||||
private validateEndDate(end:string,start:string){ const today=this.today(); if(end<start) throw new BadRequestException({code:'INVALID_VALIDITY_RANGE',message:'La fecha de finalización no puede ser anterior al inicio'}); if(end>today) throw new BadRequestException({code:'FUTURE_END_DATE',message:'La fecha de finalización no puede estar en el futuro'}); }
|
||||
private validateDateRange(start:string|null,end:string|null){ if(start&&end&&end<start) throw new BadRequestException({code:'INVALID_VALIDITY_RANGE',message:'La vigencia hasta no puede ser anterior a la vigencia desde'}); }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user