Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4a05e320b |
@@ -1,33 +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_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
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
MAIL_FROM=
|
||||
@@ -1,64 +0,0 @@
|
||||
name: Android APK
|
||||
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'feature/f2-2*'
|
||||
- 'feature/f2-3*'
|
||||
- 'feature/f2-4*'
|
||||
- 'feature/f3-1*'
|
||||
- 'feature/f3-2*'
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- '.github/workflows/android.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- 'api-v3/src/auth/**'
|
||||
- '.github/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-debug-apk:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
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: Assemble debug
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:assembleDebug
|
||||
|
||||
- name: Unit tests
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:testDebugUnitTest
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: DH-Inspeccion-F3.2-0.12.0-debug
|
||||
path: android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -1,78 +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 Compose
|
||||
run: docker compose --env-file .env.example config >/dev/null
|
||||
- name: VPS-equivalent isolated API preflight
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
|
||||
docker build --target builder -t "$image" api-v3
|
||||
docker run --rm \
|
||||
-v "$PWD/api-v3/test:/app/test:ro" \
|
||||
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||
"$image" npm test
|
||||
docker image rm "$image" >/dev/null 2>&1 || true
|
||||
- name: Build production images
|
||||
run: docker compose --env-file .env.example build api migrate web
|
||||
@@ -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,89 +0,0 @@
|
||||
name: F4 Document Flow CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'feature/f4*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'api-v3/**'
|
||||
- 'web-v2/**'
|
||||
- 'android-app/**'
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/f4-ci.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API · F4
|
||||
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:f4-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
|
||||
-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,10 +0,0 @@
|
||||
# DH Inspección V2
|
||||
|
||||
Repositorio del sistema DH Inspección V2.
|
||||
|
||||
## Estructura
|
||||
|
||||
- `api-v3/`: API v3.
|
||||
- `web-v2/`: aplicación web.
|
||||
- `scripts/`: utilidades operativas y de despliegue.
|
||||
- `docs/`: documentación técnica.
|
||||
@@ -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 = 19
|
||||
versionName = "0.12.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,34 +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-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
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,631 +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 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()
|
||||
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()
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearFindingState()
|
||||
loadActsInternal(id, selectDraft = true)
|
||||
}
|
||||
|
||||
fun closeVisitView() {
|
||||
visit = null
|
||||
inventory = emptyList()
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearActState()
|
||||
clearFindingState()
|
||||
loadVisits()
|
||||
}
|
||||
|
||||
fun startVisit() {
|
||||
val id = visit?.id ?: return
|
||||
launchBusy {
|
||||
visit = repository.startVisit(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() {
|
||||
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 (asset == null) {
|
||||
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
|
||||
return
|
||||
}
|
||||
if (acts.any { it.status == "DRAFT" }) {
|
||||
error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code)
|
||||
selectedAct = created
|
||||
actClosure = actsRepository.closure(created.id)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchInventory(search: String, parentId: String? = null) {
|
||||
val id = visit?.id ?: return
|
||||
launchBusy {
|
||||
inventory = repository.fieldInventory(id, search, parentId).data
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFieldTypes(parentId: String? = null) {
|
||||
val id = visit?.id ?: return
|
||||
launchBusy {
|
||||
fieldTypes = repository.fieldTypes(id, parentId).data
|
||||
}
|
||||
}
|
||||
|
||||
fun selectExisting(item: FieldInventoryItem) {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy {
|
||||
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
||||
notice = "Inventario agregado a la inspección."
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
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 familia técnica o la opción Otro / no catalogado."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
val request = CreateFieldInventoryRequest(
|
||||
typeId = type.id,
|
||||
parentId = parentId,
|
||||
familyId = familyId,
|
||||
name = name.trim(),
|
||||
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
|
||||
attributes = attributes,
|
||||
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."
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
}
|
||||
}
|
||||
|
||||
fun mergeCreatedFieldAsset(canonicalAssetId: String, reason: String) {
|
||||
val visitId = visit?.id ?: return
|
||||
val source = selectedFieldAsset?.asset ?: return
|
||||
if (canonicalAssetId.isBlank()) {
|
||||
error = "Elegí el registro existente que se conservará."
|
||||
return
|
||||
}
|
||||
if (reason.trim().length < 8) {
|
||||
error = "Explicá brevemente por qué se trata de un duplicado."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
val result = repository.mergeFieldAsset(
|
||||
visitId = visitId,
|
||||
assetId = source.id,
|
||||
canonicalAssetId = canonicalAssetId,
|
||||
reason = reason,
|
||||
)
|
||||
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
|
||||
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
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."
|
||||
}
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareSelectedAct() {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.prepare(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
notice = "Acta preparada. Su contenido quedó congelado para las firmas."
|
||||
}
|
||||
}
|
||||
|
||||
fun reopenSelectedAct() {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.reopen(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
notice = "Acta reabierta. Podés corregirla antes de volver a preparar."
|
||||
}
|
||||
}
|
||||
|
||||
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 (reason.trim().length < 10) {
|
||||
error = "Indicá un motivo de al menos 10 caracteres."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
actClosure = actsRepository.companyOutcome(actId, status, reason)
|
||||
notice = if (status == "ABSENT") "Ausencia de empresa asentada." else "Negativa a firmar asentada."
|
||||
}
|
||||
}
|
||||
|
||||
fun closeSelectedAct() {
|
||||
val currentVisit = visit ?: return
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.closeAct(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
clearFindingState()
|
||||
notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección."
|
||||
}
|
||||
}
|
||||
|
||||
fun closeInspection() {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy {
|
||||
visit = actsRepository.closeVisit(visitId)
|
||||
loadVisitsInternal()
|
||||
notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para 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,580 +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 title: String? = null,
|
||||
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 title: String? = null,
|
||||
val objective: String? = null,
|
||||
val status: String,
|
||||
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,447 +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 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 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 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 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 MobileCloseActRequest(
|
||||
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}/ready")
|
||||
suspend fun ready(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/reopen")
|
||||
suspend fun reopen(
|
||||
@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}/close")
|
||||
suspend fun closeAct(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: MobileCloseActRequest,
|
||||
): 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, visitCode: String): MobileActDetail = authorized { session ->
|
||||
api.createAct(
|
||||
"Bearer ${session.accessToken}",
|
||||
visitId,
|
||||
CreateMobileActRequest(
|
||||
occurredAt = Instant.now().toString(),
|
||||
title = "Acta de inspección $visitCode",
|
||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.",
|
||||
assetIds = listOf(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 prepare(actId: String): MobileActClosure = authorized { session ->
|
||||
api.ready("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun reopen(actId: String): MobileActClosure = authorized { session ->
|
||||
api.reopen("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 closeAct(actId: String): MobileActClosure = authorized { session ->
|
||||
api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,579 +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)
|
||||
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) }
|
||||
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,762 +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 })
|
||||
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.plannedStartAt?.let {
|
||||
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
visit.instructions?.takeIf { it.isNotBlank() }?.let {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text("Instrucciones", fontWeight = FontWeight.Bold)
|
||||
Text(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
F3MessageStrip(model)
|
||||
|
||||
if (visit.status == "PLANNED") {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold)
|
||||
Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.")
|
||||
Button(
|
||||
onClick = { model.startVisit() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visit.status == "IN_PROGRESS") {
|
||||
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Abrir Inventario de campo")
|
||||
}
|
||||
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
|
||||
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
|
||||
}
|
||||
} else if (visit.status == "CLOSED") {
|
||||
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Ver Actas · ${model.acts.size}")
|
||||
}
|
||||
} else if (visit.status == "PLANNED") {
|
||||
Text(
|
||||
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
F3ChecklistCard(visit)
|
||||
|
||||
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (visit.planningAssets.none { it.included }) {
|
||||
Text("Sin elementos planificados.")
|
||||
}
|
||||
visit.planningAssets.filter { it.included }.forEach { asset ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(asset.name, fontWeight = FontWeight.SemiBold)
|
||||
Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3ChecklistCard(visit: VisitDetail) {
|
||||
val checklist = visit.checklist
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Antecedentes antes de salir", fontWeight = FontWeight.Bold)
|
||||
Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}")
|
||||
Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}")
|
||||
if (checklist.stale) {
|
||||
Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val visit = model.visit ?: return
|
||||
|
||||
var search by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
|
||||
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") }
|
||||
var name by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
val attributeValues = remember { mutableStateMapOf<String, String>() }
|
||||
|
||||
var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(visit.id) {
|
||||
model.searchInventory("")
|
||||
model.loadFieldTypes(null)
|
||||
}
|
||||
LaunchedEffect(model.fieldTypes) {
|
||||
if (model.fieldTypes.none { it.id == selectedTypeId }) {
|
||||
selectedTypeId = model.fieldTypes.firstOrNull()?.id
|
||||
selectedFamilyId = null
|
||||
attributeValues.clear()
|
||||
}
|
||||
}
|
||||
|
||||
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
|
||||
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
|
||||
|
||||
fun resetCreateForm() {
|
||||
name = ""
|
||||
commonName = ""
|
||||
selectedFamilyId = null
|
||||
attributeValues.clear()
|
||||
}
|
||||
|
||||
val createWithLocation: () -> Unit = {
|
||||
val type = selectedType
|
||||
if (type != null) {
|
||||
scope.launch {
|
||||
runCatching { currentF3Geo(context) }
|
||||
.onSuccess { geo ->
|
||||
model.createFieldAsset(
|
||||
type = type,
|
||||
parentId = parentId,
|
||||
familyId = selectedFamilyId,
|
||||
name = name,
|
||||
commonName = commonName,
|
||||
attributes = buildF3Attributes(type, attributeValues),
|
||||
latitude = geo.latitude,
|
||||
longitude = geo.longitude,
|
||||
accuracyM = geo.accuracyM,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val locationPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { result ->
|
||||
val allowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
||||
if (allowed) createWithLocation()
|
||||
}
|
||||
|
||||
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
|
||||
var pendingPhotoGeo by remember { mutableStateOf<F3GeoSnapshot?>(null) }
|
||||
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
val file = pendingPhotoFile
|
||||
val geo = pendingPhotoGeo
|
||||
if (success && file != null && geo != null) {
|
||||
runCatching { writeF3Exif(file, geo) }
|
||||
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
|
||||
}
|
||||
pendingPhotoFile = null
|
||||
pendingPhotoGeo = null
|
||||
}
|
||||
val beginPhoto: () -> Unit = {
|
||||
scope.launch {
|
||||
runCatching { currentF3Geo(context) }.onSuccess { geo ->
|
||||
val (file, uri) = newF3Photo(context)
|
||||
pendingPhotoFile = file
|
||||
pendingPhotoGeo = geo
|
||||
takePicture.launch(uri)
|
||||
}
|
||||
}
|
||||
}
|
||||
val photoPermissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { result ->
|
||||
val camera = result[Manifest.permission.CAMERA] == true || f3HasPermission(context, Manifest.permission.CAMERA)
|
||||
val location = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || f3HasLocation(context)
|
||||
if (camera && location) beginPhoto()
|
||||
}
|
||||
|
||||
val selectedCapture = model.selectedFieldAsset
|
||||
val mergeSource = selectedCapture?.takeIf {
|
||||
it.capture.captureRequired &&
|
||||
it.asset.type?.let { type -> f3TypeCode(type) in setOf("instalacion", "subinstalacion") } == true
|
||||
}
|
||||
val mergeCandidates = remember(model.inventory, mergeSource) {
|
||||
if (mergeSource == null) emptyList() else model.inventory.filter { candidate ->
|
||||
candidate.id != mergeSource.asset.id &&
|
||||
candidate.informationStatus != "INACTIVE" &&
|
||||
candidate.type?.id == mergeSource.asset.type?.id &&
|
||||
candidate.parent?.id == mergeSource.asset.parent?.id &&
|
||||
!candidate.captureRequired
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onBack) { Text("Volver") }
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Área → Yacimiento → Instalación → Subinstalación", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
|
||||
|
||||
if (selectedCapture != null) {
|
||||
F3CaptureCard(
|
||||
detailName = selectedCapture.asset.name,
|
||||
captureRequired = selectedCapture.capture.captureRequired,
|
||||
gps = selectedCapture.capture.creationGpsCaptured,
|
||||
photos = selectedCapture.capture.fieldPhotoCount,
|
||||
ready = selectedCapture.capture.readyForFinding,
|
||||
onPhoto = {
|
||||
val permissions = arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
)
|
||||
if (f3HasPermission(context, Manifest.permission.CAMERA) && f3HasLocation(context)) beginPhoto()
|
||||
else photoPermissionLauncher.launch(permissions)
|
||||
},
|
||||
onClose = { model.clearSelectedFieldAsset() },
|
||||
)
|
||||
}
|
||||
|
||||
if (mergeSource != null) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("¿Lo acabás de crear y ya existía?", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Podés fusionar esta alta de campo con el registro existente. La ficha nueva no se borra: queda como alias histórico con fecha, inspector e inspección.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = mergeSearch,
|
||||
onValueChange = { mergeSearch = it },
|
||||
label = { Text("Buscar posible duplicado") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton(onClick = {
|
||||
mergeCandidateId = null
|
||||
model.searchInventory(mergeSearch, mergeSource.asset.parent?.id)
|
||||
}) { Text("Buscar") }
|
||||
}
|
||||
if (mergeCandidates.isEmpty()) {
|
||||
Text("No hay coincidencias compatibles en esta búsqueda.", style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
mergeCandidates.take(8).forEach { candidate ->
|
||||
AssistChip(
|
||||
onClick = { mergeCandidateId = candidate.id },
|
||||
label = {
|
||||
Text(
|
||||
if (mergeCandidateId == candidate.id) "✓ ${candidate.code} · ${candidate.name}"
|
||||
else "${candidate.code} · ${candidate.name}",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = mergeReason,
|
||||
onValueChange = { mergeReason = it },
|
||||
label = { Text("Motivo de la fusión") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val target = mergeCandidateId ?: return@Button
|
||||
model.mergeCreatedFieldAsset(target, mergeReason)
|
||||
},
|
||||
enabled = mergeCandidateId != null && mergeReason.trim().length >= 8 && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Fusionar y conservar el registro existente") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Buscar por nombre o código") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text("Alta en campo", fontWeight = FontWeight.Bold)
|
||||
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
showCreate = !showCreate
|
||||
if (showCreate) model.loadFieldTypes(parentId)
|
||||
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
|
||||
}
|
||||
|
||||
if (showCreate) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.weight(1f, fill = false),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (parentId != null) {
|
||||
OutlinedButton(onClick = {
|
||||
parentId = null
|
||||
parentLabel = "Área de la inspección"
|
||||
resetCreateForm()
|
||||
selectedTypeId = null
|
||||
model.loadFieldTypes(null)
|
||||
}) { Text("Volver al Área") }
|
||||
}
|
||||
|
||||
if (model.fieldTypes.isEmpty()) {
|
||||
Text("Este nivel no admite más hijos estructurales.")
|
||||
} else {
|
||||
Text("Vas a crear", style = MaterialTheme.typography.bodySmall)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(model.fieldTypes, key = { it.id }) { type ->
|
||||
AssistChip(
|
||||
onClick = {
|
||||
selectedTypeId = type.id
|
||||
selectedFamilyId = null
|
||||
attributeValues.clear()
|
||||
},
|
||||
label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedType?.familyRequired == true) {
|
||||
Text("Familia técnica", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(selectedType.families, key = { it.id }) { family ->
|
||||
AssistChip(
|
||||
onClick = { selectedFamilyId = family.id },
|
||||
label = {
|
||||
val prefix = when {
|
||||
selectedFamilyId == family.id -> "✓ "
|
||||
family.isOther -> "+ "
|
||||
else -> ""
|
||||
}
|
||||
Text(prefix + family.name)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
selectedFamily?.let { family ->
|
||||
if (family.isOther) {
|
||||
Text(
|
||||
"Se registrará como familia no catalogada para revisión posterior en oficina.",
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (family.informationLabels.isNotEmpty()) {
|
||||
Text(
|
||||
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Nombre identificable *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = commonName,
|
||||
onValueChange = { commonName = it },
|
||||
label = { Text("Nombre habitual") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
selectedType?.attributes?.forEach { definition ->
|
||||
OutlinedTextField(
|
||||
value = attributeValues[definition.code].orEmpty(),
|
||||
onValueChange = { attributeValues[definition.code] = it },
|
||||
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
|
||||
supportingText = {
|
||||
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
|
||||
if (details.isNotBlank()) Text(details)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (
|
||||
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
|
||||
) KeyboardType.Decimal else KeyboardType.Text,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
val attributesReady = selectedType?.attributes
|
||||
?.filter { it.isRequired }
|
||||
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
|
||||
?: false
|
||||
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
||||
Button(
|
||||
onClick = {
|
||||
if (f3HasLocation(context)) createWithLocation()
|
||||
else locationPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Capturar GPS y crear") }
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
|
||||
Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(model.inventory, key = { it.id }) { item ->
|
||||
F3InventoryCard(
|
||||
item = item,
|
||||
onInspect = { model.selectExisting(item) },
|
||||
onUseParent = {
|
||||
parentId = item.id
|
||||
parentLabel = "${item.name} (${item.code})"
|
||||
showCreate = true
|
||||
selectedTypeId = null
|
||||
resetCreateForm()
|
||||
model.loadFieldTypes(item.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(30.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3CaptureCard(
|
||||
detailName: String,
|
||||
captureRequired: Boolean,
|
||||
gps: Boolean,
|
||||
photos: Int,
|
||||
ready: Boolean,
|
||||
onPhoto: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(detailName, fontWeight = FontWeight.Bold)
|
||||
OutlinedButton(onClick = onClose) { Text("Cerrar") }
|
||||
}
|
||||
if (captureRequired) {
|
||||
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
|
||||
if (!ready) {
|
||||
Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
|
||||
} else {
|
||||
Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
} else {
|
||||
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3InventoryCard(
|
||||
item: FieldInventoryItem,
|
||||
onInspect: () -> Unit,
|
||||
onUseParent: () -> Unit,
|
||||
) {
|
||||
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
|
||||
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
|
||||
val canHaveChild = typeCode in setOf("yacimiento", "instalacion")
|
||||
val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí"
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(item.name, fontWeight = FontWeight.SemiBold)
|
||||
Text(structureLabel(typeCode), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(item.code, style = MaterialTheme.typography.bodySmall)
|
||||
item.commonName?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (canHaveFinding) {
|
||||
Text(
|
||||
if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente",
|
||||
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
|
||||
}
|
||||
}
|
||||
if (canHaveChild) {
|
||||
OutlinedButton(onClick = onUseParent, modifier = Modifier.fillMaxWidth()) { Text(childLabel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F3MessageStrip(model: MainViewModel) {
|
||||
val error = model.error
|
||||
val notice = model.notice
|
||||
if (error != null || notice != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer
|
||||
else MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
onClick = { model.clearMessages() },
|
||||
) {
|
||||
Text(error ?: notice.orEmpty(), Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun f3TypeCode(summary: com.korexlabs.dhinspeccion.data.AssetSummary): String =
|
||||
(summary.typeName ?: summary.name).trim().lowercase()
|
||||
.replace('ó', 'o').replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ú', 'u')
|
||||
|
||||
private fun structureLabel(typeCode: String): String = when (typeCode) {
|
||||
"area" -> "Área"
|
||||
"yacimiento" -> "Yacimiento"
|
||||
"instalacion" -> "Instalación"
|
||||
"subinstalacion" -> "Subinstalación"
|
||||
else -> typeCode.ifBlank { "Inventario" }
|
||||
}
|
||||
|
||||
private fun statusLabel(status: String): String = when (status) {
|
||||
"PLANNED" -> "Planificada"
|
||||
"IN_PROGRESS" -> "En curso"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
}
|
||||
|
||||
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
||||
type.attributes.mapNotNull { definition ->
|
||||
val raw = values[definition.code]?.trim().orEmpty()
|
||||
if (raw.isBlank()) return@mapNotNull null
|
||||
definition.code to coerceF3Attribute(definition, raw)
|
||||
}.toMap()
|
||||
|
||||
private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
|
||||
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
|
||||
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
|
||||
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "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)
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) {
|
||||
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
} else {
|
||||
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
private fun newF3Photo(context: Context): Pair<File, Uri> {
|
||||
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
|
||||
directory.mkdirs()
|
||||
val file = File.createTempFile("DH_F3_${System.currentTimeMillis()}_", ".jpg", directory)
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
return file to uri
|
||||
}
|
||||
|
||||
private fun writeF3Exif(file: File, geo: F3GeoSnapshot) {
|
||||
val now = Instant.now()
|
||||
val exif = ExifInterface(file)
|
||||
exif.setLatLong(geo.latitude, geo.longitude)
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
|
||||
exif.saveAttributes()
|
||||
}
|
||||
|
||||
private fun f3ShortDate(value: String): String = value.replace('T', ' ').take(16)
|
||||
@@ -1,421 +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)
|
||||
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) }
|
||||
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 -> DhApp(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,425 +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 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()}")
|
||||
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. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
|
||||
}
|
||||
model.acts.forEach { act ->
|
||||
val active = selected?.id == act.id
|
||||
OutlinedButton(
|
||||
onClick = { model.selectAct(act.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
(if (active) "✓ " else "") +
|
||||
"${act.code} · ${actStatusLabel(act.status)} · ${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(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Nueva Acta", fontWeight = FontWeight.Bold)
|
||||
if (model.acts.any { it.status == "READY" }) {
|
||||
Text(
|
||||
"Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. Sólo se permite un borrador a la vez.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
val selectedInventory = model.selectedFieldAsset?.asset
|
||||
if (selectedInventory == null) {
|
||||
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Ir a Inventario y elegir")
|
||||
}
|
||||
} else {
|
||||
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
|
||||
Button(
|
||||
onClick = { model.createActForSelectedInventory() },
|
||||
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("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
|
||||
Text(selected.summary, style = MaterialTheme.typography.bodySmall)
|
||||
selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
|
||||
when (selected.status) {
|
||||
"DRAFT" -> {
|
||||
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
|
||||
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
|
||||
}
|
||||
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("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.")
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.")
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Preparar Acta para firmas") }
|
||||
}
|
||||
|
||||
"READY" -> {
|
||||
val signatures = closure?.signatures.orEmpty()
|
||||
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
||||
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
||||
|
||||
Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (signatures.isEmpty()) {
|
||||
OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Volver a borrador")
|
||||
}
|
||||
}
|
||||
|
||||
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("Recepción de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null) {
|
||||
val detail = when (companyOutcome.status) {
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada"
|
||||
"REFUSED" -> "Negativa a firmar"
|
||||
"ABSENT" -> "Responsable ausente"
|
||||
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.")
|
||||
Button(
|
||||
onClick = {
|
||||
model.recordCompanyOutcome(
|
||||
"ABSENT",
|
||||
closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección",
|
||||
)
|
||||
},
|
||||
enabled = !model.busy && inspectorSigned,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Asentar ausencia en el Acta") }
|
||||
} 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 && companyOutcome != null) {
|
||||
Button(
|
||||
onClick = { model.closeSelectedAct() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Acta definitivamente") }
|
||||
} else if (inspectorSigned) {
|
||||
Text(
|
||||
"La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"CLOSED" -> {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold)
|
||||
Text("El PDF, informe Word y entregas documentales se generan desde este cierre.")
|
||||
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 drafts = model.acts.count { it.status == "DRAFT" }
|
||||
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.",
|
||||
)
|
||||
Button(
|
||||
onClick = { model.closeInspection() },
|
||||
enabled = !model.busy && drafts == 0,
|
||||
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"
|
||||
"READY" -> "Preparada para firmas"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"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()
|
||||
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) }
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
@@ -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,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,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,46 +0,0 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.25.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"
|
||||
}
|
||||
}
|
||||
@@ -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 ('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 (!['CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_CLOSED', message: 'El seguimiento administrativo comienza cuando el Acta está cerrada.' });
|
||||
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,242 +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[];
|
||||
}
|
||||
|
||||
@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',
|
||||
});
|
||||
}
|
||||
|
||||
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
|
||||
|
||||
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 ('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,81 +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 { ActAdministrationModule } from './act-administration/act-administration.module';
|
||||
import { AdministrationModule } from './administration/administration.module';
|
||||
import { AssetImportsModule } from './asset-imports/asset-imports.module';
|
||||
import { AssetMasterModule } from './asset-master/asset-master.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AccessTokenGuard } from './auth/guards/access-token.guard';
|
||||
import { AuthorizationModule } from './authorization/authorization.module';
|
||||
import { PermissionsGuard } from './authorization/guards/permissions.guard';
|
||||
import { CsrfGuard } from './auth/guards/csrf.guard';
|
||||
import { PhaseADataModule } from './core-data/phase-a-data.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
|
||||
import { InspectionClosingModule } from './inspection-closing/inspection-closing.module';
|
||||
import { InspectionDeadlinesModule } from './inspection-deadlines/inspection-deadlines.module';
|
||||
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
||||
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
|
||||
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
|
||||
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.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,
|
||||
InspectionDeadlinesModule,
|
||||
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,369 +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: 'Activo no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function versionNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_VERSION_NOT_FOUND',
|
||||
message: 'Versión de activo 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 activo 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,
|
||||
'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,77 +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 { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [
|
||||
AssetTypesController,
|
||||
AssetsController,
|
||||
InventoryStructureController,
|
||||
InventoryFamilyCatalogController,
|
||||
InventoryMergeController,
|
||||
FieldInventoryMergeController,
|
||||
AssetGeometriesController,
|
||||
MapAssetsController,
|
||||
AssetHistoryController,
|
||||
AssetMediaController,
|
||||
AssetProvenanceController,
|
||||
AssetTemporalController,
|
||||
AssetOperationalRelationsController,
|
||||
AssetRegistryController,
|
||||
],
|
||||
providers: [
|
||||
AssetTypesService,
|
||||
AssetsService,
|
||||
InventoryStructureService,
|
||||
InventoryFamilyCatalogService,
|
||||
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,369 +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 (
|
||||
SELECT relation.company_id
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.area_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
UNION
|
||||
SELECT asset.operator_company_id AS company_id
|
||||
FROM assets asset
|
||||
WHERE asset.operational_area_id = $1
|
||||
AND asset.operator_company_id IS NOT NULL
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
) linked
|
||||
INNER JOIN assets company ON company.id = linked.company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
||||
WHERE 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 (
|
||||
SELECT relation.area_id
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.company_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
UNION
|
||||
SELECT asset.operational_area_id AS area_id
|
||||
FROM assets asset
|
||||
WHERE asset.operator_company_id = $1
|
||||
AND asset.operational_area_id IS NOT NULL
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
) linked
|
||||
INNER JOIN assets area ON area.id = linked.area_id
|
||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||
WHERE 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' });
|
||||
}
|
||||
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 }>;
|
||||
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),
|
||||
}, 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',
|
||||
});
|
||||
}
|
||||
if (before.assignedAssetCount > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'AREA_COMPANY_RELATION_IN_USE',
|
||||
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
|
||||
});
|
||||
}
|
||||
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),
|
||||
}, 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) 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'}); }
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { AssetTemporalService } from './asset-temporal.service';
|
||||
import {
|
||||
ListTemporalAssetsQueryDto,
|
||||
TemporalAtQueryDto,
|
||||
} from './dto/list-temporal-assets-query.dto';
|
||||
|
||||
@Controller('temporal-assets')
|
||||
export class AssetTemporalController {
|
||||
constructor(private readonly temporal: AssetTemporalService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read_temporal')
|
||||
list(@Query() query: ListTemporalAssetsQueryDto) {
|
||||
return this.temporal.list(query);
|
||||
}
|
||||
|
||||
@Get(':assetId')
|
||||
@RequirePermissions('assets.read_temporal')
|
||||
get(
|
||||
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||
@Query() query: TemporalAtQueryDto,
|
||||
) {
|
||||
return this.temporal.get(assetId, query);
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
AssetVersionChangeType,
|
||||
AuditSource,
|
||||
} from '../database/entities';
|
||||
import type {
|
||||
ListTemporalAssetsQueryDto,
|
||||
TemporalAtQueryDto,
|
||||
} from './dto/list-temporal-assets-query.dto';
|
||||
|
||||
export interface TemporalAssetSummary {
|
||||
id: string;
|
||||
assetId: string;
|
||||
assetCode: string;
|
||||
assetName: string;
|
||||
typeId: string;
|
||||
typeName: string;
|
||||
informationStatus: string;
|
||||
operationalStatus: string;
|
||||
versionNumber: number;
|
||||
changeType: AssetVersionChangeType;
|
||||
changedFields: string[];
|
||||
occurredAt: Date;
|
||||
effectiveUntil: Date | null;
|
||||
actorUserId: string | null;
|
||||
actorUsername: string | null;
|
||||
source: AuditSource;
|
||||
requestId: string | null;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface TemporalAssetDetail extends TemporalAssetSummary {
|
||||
snapshot: Record<string, unknown>;
|
||||
asOf: Date;
|
||||
}
|
||||
|
||||
function temporalAssetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_NOT_EXISTING_AT_DATE',
|
||||
message: 'El activo no tenía una versión registrada en la fecha solicitada',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetTemporalService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async list(query: ListTemporalAssetsQueryDto) {
|
||||
const parameters: unknown[] = [new Date(query.at)];
|
||||
const conditions: string[] = [];
|
||||
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}
|
||||
)`);
|
||||
}
|
||||
if (query.typeId) {
|
||||
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
||||
}
|
||||
if (query.status) {
|
||||
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const selected = this.selectedAt('$1');
|
||||
const [countRow] = (await this.dataSource.query(
|
||||
`WITH selected_version AS (${selected})
|
||||
SELECT COUNT(*)::integer AS total
|
||||
FROM selected_version version
|
||||
${where}`,
|
||||
parameters,
|
||||
)) as Array<{ total: number }>;
|
||||
const total = Number(countRow?.total ?? 0);
|
||||
const paginated = [
|
||||
...parameters,
|
||||
query.pageSize,
|
||||
(query.page - 1) * query.pageSize,
|
||||
];
|
||||
const limit = `$${parameters.length + 1}`;
|
||||
const offset = `$${parameters.length + 2}`;
|
||||
const data = (await this.dataSource.query(
|
||||
`WITH selected_version AS (${selected})
|
||||
${this.selectSummary()}
|
||||
FROM selected_version version
|
||||
INNER JOIN assets current_asset ON current_asset.id = version.asset_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT candidate.occurred_at
|
||||
FROM asset_versions candidate
|
||||
WHERE candidate.asset_id = version.asset_id
|
||||
AND candidate.version_number > version.version_number
|
||||
ORDER BY candidate.version_number ASC
|
||||
LIMIT 1
|
||||
) next_version ON true
|
||||
${where}
|
||||
ORDER BY "assetName" ASC, "assetCode" ASC
|
||||
LIMIT ${limit} OFFSET ${offset}`,
|
||||
paginated,
|
||||
)) as TemporalAssetSummary[];
|
||||
|
||||
return {
|
||||
data,
|
||||
asOf: new Date(query.at),
|
||||
meta: {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async get(assetId: string, query: TemporalAtQueryDto): Promise<TemporalAssetDetail> {
|
||||
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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT candidate.occurred_at
|
||||
FROM asset_versions candidate
|
||||
WHERE candidate.asset_id = version.asset_id
|
||||
AND candidate.version_number > version.version_number
|
||||
ORDER BY candidate.version_number ASC
|
||||
LIMIT 1
|
||||
) next_version ON true
|
||||
WHERE version.asset_id = $1
|
||||
AND version.occurred_at <= $2
|
||||
ORDER BY version.occurred_at DESC, version.version_number DESC
|
||||
LIMIT 1`,
|
||||
[assetId, new Date(query.at)],
|
||||
)) as TemporalAssetDetail[];
|
||||
if (!row) throw temporalAssetNotFound();
|
||||
const asOf = new Date(query.at);
|
||||
row.asOf = asOf;
|
||||
const [context] = (await this.dataSource.query(`
|
||||
SELECT
|
||||
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
|
||||
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany"
|
||||
FROM asset_context_history history
|
||||
LEFT JOIN assets parent ON parent.id=history.parent_id
|
||||
LEFT JOIN assets area ON area.id=history.operational_area_id
|
||||
LEFT JOIN assets company ON company.id=history.operator_company_id
|
||||
WHERE history.asset_id=$1
|
||||
AND history.valid_from <= $2
|
||||
AND (history.valid_until IS NULL OR history.valid_until > $2)
|
||||
ORDER BY history.valid_from DESC
|
||||
LIMIT 1
|
||||
`, [assetId, asOf])) as Array<{ parent: Record<string, unknown> | null; operationalArea: Record<string, unknown> | null; operatorCompany: Record<string, unknown> | null }>;
|
||||
if (context && row.snapshot) {
|
||||
row.snapshot = {
|
||||
...row.snapshot,
|
||||
parent: context.parent,
|
||||
operationalArea: context.operationalArea,
|
||||
operatorCompany: context.operatorCompany,
|
||||
};
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private selectedAt(atParameter: string): string {
|
||||
return `SELECT DISTINCT ON (candidate.asset_id) candidate.*
|
||||
FROM asset_versions candidate
|
||||
WHERE candidate.occurred_at <= ${atParameter}
|
||||
ORDER BY candidate.asset_id, candidate.occurred_at DESC, candidate.version_number DESC`;
|
||||
}
|
||||
|
||||
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",
|
||||
next_version.occurred_at AS "effectiveUntil",
|
||||
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"`;
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
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 { AssetTypesService } from './asset-types.service';
|
||||
import { CreateAssetTypeDto } from './dto/create-asset-type.dto';
|
||||
import { UpdateAssetTypeDto } from './dto/update-asset-type.dto';
|
||||
import { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto';
|
||||
import { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto';
|
||||
|
||||
@Controller('asset-types')
|
||||
export class AssetTypesController {
|
||||
constructor(private readonly assetTypes: AssetTypesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('asset_types.read')
|
||||
list() {
|
||||
return this.assetTypes.list();
|
||||
}
|
||||
|
||||
@Get('bootstrap-status')
|
||||
@RequirePermissions('asset_types.read')
|
||||
bootstrapStatus() {
|
||||
return this.assetTypes.bootstrapStatus();
|
||||
}
|
||||
|
||||
@Post('bootstrap-defaults')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
bootstrapDefaults(
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assetTypes.bootstrapDefaults(principal, request);
|
||||
}
|
||||
|
||||
@Get('enrichment-status')
|
||||
@RequirePermissions('asset_types.read')
|
||||
enrichmentStatus() {
|
||||
return this.assetTypes.enrichmentStatus();
|
||||
}
|
||||
|
||||
@Post('enrich-defaults')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
enrichDefaults(
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assetTypes.enrichDefaults(principal, request);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('asset_types.manage')
|
||||
create(
|
||||
@Body() dto: CreateAssetTypeDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assetTypes.create(dto, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('asset_types.read')
|
||||
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.assetTypes.getById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpdateAssetTypeDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assetTypes.update(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post(':id/attributes')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
createAttribute(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: CreateAttributeDefinitionDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assetTypes.createAttribute(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Patch(':id/attributes/:attributeId')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
updateAttribute(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Param('attributeId', new ParseUUIDPipe({ version: '4' })) attributeId: string,
|
||||
@Body() dto: UpdateAttributeDefinitionDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assetTypes.updateAttribute(
|
||||
id,
|
||||
attributeId,
|
||||
dto,
|
||||
principal,
|
||||
request,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,905 +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 {
|
||||
AssetAttributeDataType,
|
||||
AssetAttributeDefinition,
|
||||
AssetType,
|
||||
AssetTypeOperationalRole,
|
||||
AssetTypeParentRule,
|
||||
AuditAction,
|
||||
} from '../database/entities';
|
||||
import {
|
||||
administrationAuditContext,
|
||||
isUniqueViolation,
|
||||
} from '../administration/common/administration-audit';
|
||||
import type { CreateAssetTypeDto } from './dto/create-asset-type.dto';
|
||||
import type { UpdateAssetTypeDto } from './dto/update-asset-type.dto';
|
||||
import type { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto';
|
||||
import type { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto';
|
||||
import {
|
||||
MASTER_BOOTSTRAP_PRESET_CODE,
|
||||
MASTER_BOOTSTRAP_PRESET_NAME,
|
||||
MASTER_BOOTSTRAP_TYPES,
|
||||
MASTER_BOOTSTRAP_CORE_CODES,
|
||||
} from './asset-master-bootstrap';
|
||||
|
||||
export interface AssetAttributeDefinitionView {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
dataType: AssetAttributeDataType;
|
||||
isRequired: boolean;
|
||||
isActive: boolean;
|
||||
unit: string | null;
|
||||
options: string[] | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface AssetTypeSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
operationalRole: AssetTypeOperationalRole;
|
||||
}
|
||||
|
||||
export interface AssetTypeView extends AssetTypeSummary {
|
||||
description: string;
|
||||
canBeRoot: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
assetCount: number;
|
||||
allowedParentTypes: AssetTypeSummary[];
|
||||
attributes: AssetAttributeDefinitionView[];
|
||||
}
|
||||
|
||||
export interface MasterBootstrapStatus {
|
||||
presetCode: string;
|
||||
presetName: string;
|
||||
typeCount: number;
|
||||
canApply: boolean;
|
||||
reason: string | null;
|
||||
types: Array<{
|
||||
code: string;
|
||||
name: string;
|
||||
operationalRole: AssetTypeOperationalRole;
|
||||
parentCodes: string[];
|
||||
attributeCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MasterBootstrapResult {
|
||||
presetCode: string;
|
||||
presetName: string;
|
||||
createdTypeCount: number;
|
||||
createdAttributeCount: number;
|
||||
createdParentRuleCount: number;
|
||||
data: AssetTypeView[];
|
||||
}
|
||||
|
||||
export interface MasterEnrichmentStatus {
|
||||
presetCode: string;
|
||||
presetName: string;
|
||||
typeCount: number;
|
||||
canApply: boolean;
|
||||
complete: boolean;
|
||||
reason: string | null;
|
||||
missingTypeCodes: string[];
|
||||
missingAttributeCount: number;
|
||||
missingParentRuleCount: number;
|
||||
}
|
||||
|
||||
function typeNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_TYPE_NOT_FOUND',
|
||||
message: 'Tipo de activo no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function attributeNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_ATTRIBUTE_NOT_FOUND',
|
||||
message: 'Atributo configurable no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetTypesService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
async bootstrapStatus(): Promise<MasterBootstrapStatus> {
|
||||
const [row] = (await this.dataSource.query(
|
||||
'SELECT COUNT(*)::integer AS count FROM asset_types',
|
||||
)) as Array<{ count: number }>;
|
||||
const typeCount = Number(row?.count ?? 0);
|
||||
return {
|
||||
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||
typeCount,
|
||||
canApply: typeCount === 0,
|
||||
reason: typeCount === 0
|
||||
? null
|
||||
: 'La configuración inicial sólo puede aplicarse cuando el Maestro no tiene tipos de activo.',
|
||||
types: MASTER_BOOTSTRAP_TYPES.map((type) => ({
|
||||
code: type.code,
|
||||
name: type.name,
|
||||
operationalRole: type.operationalRole,
|
||||
parentCodes: [...type.allowedParentCodes],
|
||||
attributeCount: type.attributes.length,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async bootstrapDefaults(
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<MasterBootstrapResult> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query('LOCK TABLE asset_types IN SHARE ROW EXCLUSIVE MODE');
|
||||
const [existing] = (await manager.query(
|
||||
'SELECT COUNT(*)::integer AS count FROM asset_types',
|
||||
)) as Array<{ count: number }>;
|
||||
if (Number(existing?.count ?? 0) !== 0) {
|
||||
throw new ConflictException({
|
||||
code: 'MASTER_BOOTSTRAP_REQUIRES_EMPTY_MASTER',
|
||||
message: 'La configuración inicial sólo puede aplicarse cuando el Maestro está vacío',
|
||||
});
|
||||
}
|
||||
|
||||
const typeRepository = manager.getRepository(AssetType);
|
||||
const ruleRepository = manager.getRepository(AssetTypeParentRule);
|
||||
const attributeRepository = manager.getRepository(AssetAttributeDefinition);
|
||||
const idsByCode = new Map<string, string>();
|
||||
|
||||
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||
const type = typeRepository.create({
|
||||
code: preset.code,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
canBeRoot: preset.canBeRoot,
|
||||
isActive: true,
|
||||
operationalRole: preset.operationalRole,
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
});
|
||||
const saved = await typeRepository.save(type);
|
||||
idsByCode.set(preset.code, saved.id);
|
||||
}
|
||||
|
||||
let createdParentRuleCount = 0;
|
||||
let createdAttributeCount = 0;
|
||||
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||
const childTypeId = idsByCode.get(preset.code)!;
|
||||
const rules = preset.allowedParentCodes.map((parentCode) =>
|
||||
ruleRepository.create({
|
||||
childTypeId,
|
||||
parentTypeId: idsByCode.get(parentCode)!,
|
||||
}),
|
||||
);
|
||||
if (rules.length) {
|
||||
await ruleRepository.save(rules);
|
||||
createdParentRuleCount += rules.length;
|
||||
}
|
||||
|
||||
const attributes = preset.attributes.map((attribute) =>
|
||||
attributeRepository.create({
|
||||
assetTypeId: childTypeId,
|
||||
code: attribute.code,
|
||||
name: attribute.name,
|
||||
dataType: attribute.dataType,
|
||||
isRequired: attribute.isRequired,
|
||||
isActive: true,
|
||||
unit: attribute.unit,
|
||||
options: attribute.options,
|
||||
sortOrder: attribute.sortOrder,
|
||||
}),
|
||||
);
|
||||
if (attributes.length) {
|
||||
await attributeRepository.save(attributes);
|
||||
createdAttributeCount += attributes.length;
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await manager.query(this.viewQuery(''), [])) as AssetTypeView[];
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_MASTER_BOOTSTRAPPED,
|
||||
entityType: 'asset_master',
|
||||
entityId: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||
afterData: {
|
||||
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||
mode: 'INITIAL_BOOTSTRAP',
|
||||
createdTypeCount: MASTER_BOOTSTRAP_TYPES.length,
|
||||
createdAttributeCount,
|
||||
createdParentRuleCount,
|
||||
},
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
return {
|
||||
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||
createdTypeCount: MASTER_BOOTSTRAP_TYPES.length,
|
||||
createdAttributeCount,
|
||||
createdParentRuleCount,
|
||||
data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async enrichmentStatus(): Promise<MasterEnrichmentStatus> {
|
||||
return this.dataSource.transaction((manager) => this.computeEnrichmentStatus(manager));
|
||||
}
|
||||
|
||||
async enrichDefaults(
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<MasterBootstrapResult> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await manager.query('LOCK TABLE asset_types IN SHARE ROW EXCLUSIVE MODE');
|
||||
const before = await this.computeEnrichmentStatus(manager);
|
||||
if (!before.canApply) {
|
||||
throw new ConflictException({
|
||||
code: 'MASTER_ENRICHMENT_INCOMPATIBLE',
|
||||
message: before.reason ?? 'El Maestro actual no es compatible con el catálogo técnico',
|
||||
});
|
||||
}
|
||||
|
||||
const typeRepository = manager.getRepository(AssetType);
|
||||
const ruleRepository = manager.getRepository(AssetTypeParentRule);
|
||||
const attributeRepository = manager.getRepository(AssetAttributeDefinition);
|
||||
const existingTypes = await typeRepository.find();
|
||||
const typesByCode = new Map<string, AssetType>(existingTypes.map((type) => [type.code, type]));
|
||||
let createdTypeCount = 0;
|
||||
let createdAttributeCount = 0;
|
||||
let createdParentRuleCount = 0;
|
||||
|
||||
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||
if (typesByCode.has(preset.code)) continue;
|
||||
const saved = await typeRepository.save(typeRepository.create({
|
||||
code: preset.code,
|
||||
name: preset.name,
|
||||
description: preset.description,
|
||||
canBeRoot: preset.canBeRoot,
|
||||
isActive: true,
|
||||
operationalRole: preset.operationalRole,
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
}));
|
||||
typesByCode.set(preset.code, saved);
|
||||
createdTypeCount += 1;
|
||||
}
|
||||
|
||||
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||
const childType = typesByCode.get(preset.code)!;
|
||||
const existingAttributes = await attributeRepository.find({
|
||||
where: { assetTypeId: childType.id },
|
||||
});
|
||||
const attributeCodes = new Set(existingAttributes.map((attribute) => attribute.code));
|
||||
for (const attribute of preset.attributes) {
|
||||
if (attributeCodes.has(attribute.code)) continue;
|
||||
await attributeRepository.save(attributeRepository.create({
|
||||
assetTypeId: childType.id,
|
||||
code: attribute.code,
|
||||
name: attribute.name,
|
||||
dataType: attribute.dataType,
|
||||
isRequired: attribute.isRequired,
|
||||
isActive: true,
|
||||
unit: attribute.unit,
|
||||
options: attribute.options,
|
||||
sortOrder: attribute.sortOrder,
|
||||
}));
|
||||
createdAttributeCount += 1;
|
||||
}
|
||||
|
||||
for (const parentCode of preset.allowedParentCodes) {
|
||||
const parentType = typesByCode.get(parentCode)!;
|
||||
const existing = await ruleRepository.findOne({
|
||||
where: { childTypeId: childType.id, parentTypeId: parentType.id },
|
||||
});
|
||||
if (existing) continue;
|
||||
await ruleRepository.save(ruleRepository.create({
|
||||
childTypeId: childType.id,
|
||||
parentTypeId: parentType.id,
|
||||
}));
|
||||
createdParentRuleCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await manager.query(this.viewQuery(''), [])) as AssetTypeView[];
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_MASTER_BOOTSTRAPPED,
|
||||
entityType: 'asset_master',
|
||||
entityId: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||
afterData: {
|
||||
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||
mode: 'TECHNICAL_ENRICHMENT',
|
||||
createdTypeCount,
|
||||
createdAttributeCount,
|
||||
createdParentRuleCount,
|
||||
},
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
return {
|
||||
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||
createdTypeCount,
|
||||
createdAttributeCount,
|
||||
createdParentRuleCount,
|
||||
data,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async computeEnrichmentStatus(manager: EntityManager): Promise<MasterEnrichmentStatus> {
|
||||
const existingTypes = await manager.getRepository(AssetType).find();
|
||||
const typesByCode = new Map<string, AssetType>(existingTypes.map((type) => [type.code, type]));
|
||||
const missingCore = MASTER_BOOTSTRAP_CORE_CODES.filter((code) => !typesByCode.has(code));
|
||||
const area = typesByCode.get('area');
|
||||
const company = typesByCode.get('empresa');
|
||||
const incompatible = [
|
||||
...(missingCore.length ? [`Faltan tipos base: ${missingCore.join(', ')}`] : []),
|
||||
...(area && area.operationalRole !== AssetTypeOperationalRole.AREA ? ['El tipo area no tiene rol AREA'] : []),
|
||||
...(company && company.operationalRole !== AssetTypeOperationalRole.COMPANY ? ['El tipo empresa no tiene rol COMPANY'] : []),
|
||||
];
|
||||
const canApply = incompatible.length === 0;
|
||||
const missingTypeCodes = MASTER_BOOTSTRAP_TYPES
|
||||
.filter((preset) => !typesByCode.has(preset.code))
|
||||
.map((preset) => preset.code);
|
||||
|
||||
let missingAttributeCount = 0;
|
||||
let missingParentRuleCount = 0;
|
||||
if (canApply) {
|
||||
for (const preset of MASTER_BOOTSTRAP_TYPES) {
|
||||
const childType = typesByCode.get(preset.code);
|
||||
if (!childType) {
|
||||
missingAttributeCount += preset.attributes.length;
|
||||
missingParentRuleCount += preset.allowedParentCodes.length;
|
||||
continue;
|
||||
}
|
||||
const attributeRows = (await manager.query(
|
||||
'SELECT code FROM asset_attribute_definitions WHERE asset_type_id = $1',
|
||||
[childType.id],
|
||||
)) as Array<{ code: string }>;
|
||||
const attributeCodes = new Set(attributeRows.map((row) => row.code));
|
||||
missingAttributeCount += preset.attributes.filter((attribute) => !attributeCodes.has(attribute.code)).length;
|
||||
for (const parentCode of preset.allowedParentCodes) {
|
||||
const parentType = typesByCode.get(parentCode);
|
||||
if (!parentType) {
|
||||
missingParentRuleCount += 1;
|
||||
continue;
|
||||
}
|
||||
const [rule] = (await manager.query(
|
||||
'SELECT 1 FROM asset_type_parent_rules WHERE child_type_id = $1 AND parent_type_id = $2 LIMIT 1',
|
||||
[childType.id, parentType.id],
|
||||
)) as unknown[];
|
||||
if (!rule) missingParentRuleCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const complete = canApply
|
||||
&& missingTypeCodes.length === 0
|
||||
&& missingAttributeCount === 0
|
||||
&& missingParentRuleCount === 0;
|
||||
return {
|
||||
presetCode: MASTER_BOOTSTRAP_PRESET_CODE,
|
||||
presetName: MASTER_BOOTSTRAP_PRESET_NAME,
|
||||
typeCount: existingTypes.length,
|
||||
canApply,
|
||||
complete,
|
||||
reason: canApply ? null : incompatible.join('. '),
|
||||
missingTypeCodes,
|
||||
missingAttributeCount,
|
||||
missingParentRuleCount,
|
||||
};
|
||||
}
|
||||
|
||||
async list(): Promise<{ data: AssetTypeView[] }> {
|
||||
const rows = (await this.dataSource.query(this.viewQuery(''), [])) as AssetTypeView[];
|
||||
return { data: rows };
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AssetTypeView> {
|
||||
return this.dataSource.transaction((manager) => this.loadView(manager, id));
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateAssetTypeDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetTypeView> {
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
await this.validateParentTypes(manager, null, dto.allowedParentTypeIds);
|
||||
const type = manager.getRepository(AssetType).create({
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
canBeRoot: dto.canBeRoot,
|
||||
isActive: true,
|
||||
operationalRole: dto.operationalRole,
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
});
|
||||
await manager.getRepository(AssetType).save(type);
|
||||
await this.replaceParentRules(manager, type.id, dto.allowedParentTypeIds);
|
||||
const created = await this.loadView(manager, type.id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_TYPE_CREATED,
|
||||
entityType: 'asset_type',
|
||||
entityId: type.id,
|
||||
afterData: { ...created },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_TYPE_ALREADY_EXISTS',
|
||||
message: 'Ya existe un tipo de activo con ese código',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateAssetTypeDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetTypeView> {
|
||||
if (Object.keys(dto).length === 0) {
|
||||
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const type = await manager.getRepository(AssetType).findOne({ where: { id } });
|
||||
if (!type) throw typeNotFound();
|
||||
const before = await this.loadView(manager, id);
|
||||
const nextCanBeRoot = dto.canBeRoot ?? type.canBeRoot;
|
||||
const nextOperationalRole = dto.operationalRole ?? type.operationalRole;
|
||||
const nextParentIds = dto.allowedParentTypeIds ?? before.allowedParentTypes.map((item) => item.id);
|
||||
await this.validateParentTypes(manager, id, nextParentIds);
|
||||
await this.assertExistingAssetsRemainValid(manager, id, nextCanBeRoot, nextParentIds);
|
||||
await this.assertOperationalRoleChangeAllowed(manager, id, type.operationalRole, nextOperationalRole);
|
||||
if (dto.isActive === false && type.isActive) {
|
||||
await this.assertOperationalTypeCanBeDeactivated(manager, id, type.operationalRole);
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) type.name = dto.name;
|
||||
if (dto.description !== undefined) type.description = dto.description;
|
||||
if (dto.canBeRoot !== undefined) type.canBeRoot = dto.canBeRoot;
|
||||
if (dto.isActive !== undefined) type.isActive = dto.isActive;
|
||||
if (dto.operationalRole !== undefined) type.operationalRole = dto.operationalRole;
|
||||
type.updatedBy = principal.userId;
|
||||
await manager.getRepository(AssetType).save(type);
|
||||
if (dto.allowedParentTypeIds !== undefined) {
|
||||
await this.replaceParentRules(manager, id, nextParentIds);
|
||||
}
|
||||
|
||||
const updated = await this.loadView(manager, id);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_TYPE_UPDATED,
|
||||
entityType: 'asset_type',
|
||||
entityId: id,
|
||||
beforeData: { ...before },
|
||||
afterData: { ...updated },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async createAttribute(
|
||||
typeId: string,
|
||||
dto: CreateAttributeDefinitionDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetTypeView> {
|
||||
try {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
await this.requireType(manager, typeId);
|
||||
this.validateOptions(dto.dataType, dto.options);
|
||||
if (dto.isRequired) {
|
||||
const [{ count }] = (await manager.query(
|
||||
'SELECT COUNT(*)::integer AS count FROM assets WHERE asset_type_id = $1',
|
||||
[typeId],
|
||||
)) as Array<{ count: number }>;
|
||||
if (Number(count) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'ATTRIBUTE_REQUIRED_VALUES_MISSING',
|
||||
message: 'No se puede agregar un atributo obligatorio a activos existentes sin completar sus valores',
|
||||
});
|
||||
}
|
||||
}
|
||||
const normalizedOptions = dto.options?.map((item) => item.trim());
|
||||
const definition = manager.getRepository(AssetAttributeDefinition).create({
|
||||
assetTypeId: typeId,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
dataType: dto.dataType,
|
||||
isRequired: dto.isRequired,
|
||||
isActive: true,
|
||||
unit: dto.unit ?? null,
|
||||
options: dto.dataType === AssetAttributeDataType.SELECT ? normalizedOptions! : null,
|
||||
sortOrder: dto.sortOrder,
|
||||
});
|
||||
await manager.getRepository(AssetAttributeDefinition).save(definition);
|
||||
const updated = await this.loadView(manager, typeId);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_ATTRIBUTE_CREATED,
|
||||
entityType: 'asset_attribute_definition',
|
||||
entityId: definition.id,
|
||||
afterData: { ...definition },
|
||||
metadata: { assetTypeId: typeId },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_ATTRIBUTE_ALREADY_EXISTS',
|
||||
message: 'Ya existe un atributo con ese código para el tipo seleccionado',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateAttribute(
|
||||
typeId: string,
|
||||
attributeId: string,
|
||||
dto: UpdateAttributeDefinitionDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<AssetTypeView> {
|
||||
if (Object.keys(dto).length === 0) {
|
||||
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const repository = manager.getRepository(AssetAttributeDefinition);
|
||||
const definition = await repository.findOne({
|
||||
where: { id: attributeId, assetTypeId: typeId },
|
||||
});
|
||||
if (!definition) throw attributeNotFound();
|
||||
const before = { ...definition };
|
||||
const nextType = dto.dataType ?? definition.dataType;
|
||||
const nextOptions = dto.options === undefined
|
||||
? definition.options
|
||||
: dto.options?.map((item) => item.trim()) ?? null;
|
||||
this.validateOptions(nextType, nextOptions ?? undefined);
|
||||
|
||||
if (dto.dataType !== undefined && dto.dataType !== definition.dataType) {
|
||||
const [{ count }] = (await manager.query(
|
||||
'SELECT COUNT(*)::integer AS count FROM asset_attribute_values WHERE definition_id = $1',
|
||||
[attributeId],
|
||||
)) as Array<{ count: number }>;
|
||||
if (Number(count) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'ATTRIBUTE_TYPE_IN_USE',
|
||||
message: 'No se puede cambiar el tipo de un atributo que ya tiene valores',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (dto.isRequired === true && !definition.isRequired) {
|
||||
const [{ count }] = (await manager.query(
|
||||
`SELECT COUNT(*)::integer AS count FROM assets asset
|
||||
WHERE asset.asset_type_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM asset_attribute_values value
|
||||
WHERE value.asset_id = asset.id AND value.definition_id = $2
|
||||
)`,
|
||||
[typeId, attributeId],
|
||||
)) as Array<{ count: number }>;
|
||||
if (Number(count) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'ATTRIBUTE_REQUIRED_VALUES_MISSING',
|
||||
message: 'Hay activos existentes sin valor para este atributo',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) definition.name = dto.name;
|
||||
if (dto.dataType !== undefined) definition.dataType = dto.dataType;
|
||||
if (dto.isRequired !== undefined) definition.isRequired = dto.isRequired;
|
||||
if (dto.isActive !== undefined) definition.isActive = dto.isActive;
|
||||
if (dto.unit !== undefined) definition.unit = dto.unit;
|
||||
if (dto.options !== undefined || dto.dataType !== undefined) {
|
||||
definition.options = nextType === AssetAttributeDataType.SELECT ? nextOptions : null;
|
||||
}
|
||||
if (dto.sortOrder !== undefined) definition.sortOrder = dto.sortOrder;
|
||||
await repository.save(definition);
|
||||
|
||||
const updated = await this.loadView(manager, typeId);
|
||||
await this.audit.record(
|
||||
{
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_ATTRIBUTE_UPDATED,
|
||||
entityType: 'asset_attribute_definition',
|
||||
entityId: attributeId,
|
||||
beforeData: before,
|
||||
afterData: { ...definition },
|
||||
metadata: { assetTypeId: typeId },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
private validateOptions(
|
||||
dataType: AssetAttributeDataType,
|
||||
options?: string[] | null,
|
||||
): void {
|
||||
if (dataType === AssetAttributeDataType.SELECT) {
|
||||
const cleaned = (options ?? []).map((item) => item.trim()).filter(Boolean);
|
||||
if (cleaned.length < 1 || new Set(cleaned).size !== cleaned.length) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVALID_ATTRIBUTE_OPTIONS',
|
||||
message: 'Los atributos de selección necesitan opciones únicas',
|
||||
});
|
||||
}
|
||||
} else if (options != null) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVALID_ATTRIBUTE_OPTIONS',
|
||||
message: 'Sólo los atributos de selección pueden tener opciones',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async requireType(manager: EntityManager, id: string): Promise<AssetType> {
|
||||
const type = await manager.getRepository(AssetType).findOne({ where: { id } });
|
||||
if (!type) throw typeNotFound();
|
||||
return type;
|
||||
}
|
||||
|
||||
private async validateParentTypes(
|
||||
manager: EntityManager,
|
||||
childTypeId: string | null,
|
||||
parentTypeIds: string[],
|
||||
): Promise<void> {
|
||||
if (childTypeId && parentTypeIds.includes(childTypeId)) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVALID_PARENT_TYPE_RULE',
|
||||
message: 'Un tipo no puede ser padre de sí mismo',
|
||||
});
|
||||
}
|
||||
if (parentTypeIds.length === 0) return;
|
||||
const parents = await manager.getRepository(AssetType).find({
|
||||
where: { id: In(parentTypeIds) },
|
||||
});
|
||||
if (parents.length !== parentTypeIds.length) {
|
||||
throw new BadRequestException({
|
||||
code: 'ASSET_PARENT_TYPE_NOT_FOUND',
|
||||
message: 'Uno o más tipos padre no existen',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceParentRules(
|
||||
manager: EntityManager,
|
||||
childTypeId: string,
|
||||
parentTypeIds: string[],
|
||||
): Promise<void> {
|
||||
const repository = manager.getRepository(AssetTypeParentRule);
|
||||
await repository.delete({ childTypeId });
|
||||
if (parentTypeIds.length) {
|
||||
await repository.save(
|
||||
parentTypeIds.map((parentTypeId) =>
|
||||
repository.create({ childTypeId, parentTypeId }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertExistingAssetsRemainValid(
|
||||
manager: EntityManager,
|
||||
typeId: string,
|
||||
canBeRoot: boolean,
|
||||
parentTypeIds: string[],
|
||||
): Promise<void> {
|
||||
const [row] = (await manager.query(
|
||||
`SELECT COUNT(*)::integer AS count
|
||||
FROM assets asset
|
||||
LEFT JOIN assets parent ON parent.id = asset.parent_id
|
||||
WHERE asset.asset_type_id = $1
|
||||
AND (
|
||||
(asset.parent_id IS NULL AND $2::boolean = false)
|
||||
OR (asset.parent_id IS NOT NULL AND NOT(parent.asset_type_id = ANY($3::uuid[])))
|
||||
)`,
|
||||
[typeId, canBeRoot, parentTypeIds],
|
||||
)) as Array<{ count: number }>;
|
||||
if (Number(row?.count ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_TYPE_RULES_IN_USE',
|
||||
message: 'Las reglas dejarían activos existentes fuera de una jerarquía válida',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async assertOperationalTypeCanBeDeactivated(
|
||||
manager: EntityManager,
|
||||
typeId: string,
|
||||
role: AssetTypeOperationalRole,
|
||||
): Promise<void> {
|
||||
if (role === AssetTypeOperationalRole.GENERIC) return;
|
||||
const relationColumn = role === AssetTypeOperationalRole.AREA ? 'area_id' : 'company_id';
|
||||
const assignmentColumn = role === AssetTypeOperationalRole.AREA
|
||||
? 'operational_area_id'
|
||||
: 'operator_company_id';
|
||||
const [usage] = (await manager.query(`
|
||||
SELECT (
|
||||
(SELECT COUNT(*) FROM area_company_relations relation
|
||||
INNER JOIN assets anchor ON anchor.id = relation.${relationColumn}
|
||||
WHERE anchor.asset_type_id = $1 AND relation.valid_until IS NULL)
|
||||
+
|
||||
(SELECT COUNT(*) FROM assets assigned
|
||||
INNER JOIN assets anchor ON anchor.id = assigned.${assignmentColumn}
|
||||
WHERE anchor.asset_type_id = $1)
|
||||
)::integer AS count
|
||||
`, [typeId])) as Array<{ count: number }>;
|
||||
if (Number(usage?.count ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
|
||||
message: role === AssetTypeOperationalRole.AREA
|
||||
? 'No se puede inactivar el tipo mientras sus áreas tengan relaciones o activos operativos asociados'
|
||||
: 'No se puede inactivar el tipo mientras sus empresas tengan relaciones o activos operativos asociados',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertOperationalRoleChangeAllowed(
|
||||
manager: EntityManager,
|
||||
typeId: string,
|
||||
currentRole: AssetTypeOperationalRole,
|
||||
nextRole: AssetTypeOperationalRole,
|
||||
): Promise<void> {
|
||||
if (currentRole === nextRole) return;
|
||||
|
||||
if (nextRole !== AssetTypeOperationalRole.GENERIC) {
|
||||
const [assigned] = (await manager.query(
|
||||
`SELECT COUNT(*)::integer AS count
|
||||
FROM assets
|
||||
WHERE asset_type_id = $1
|
||||
AND (operational_area_id IS NOT NULL OR operator_company_id IS NOT NULL)`,
|
||||
[typeId],
|
||||
)) as Array<{ count: number }>;
|
||||
if (Number(assigned?.count ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
|
||||
message: 'No se puede convertir el tipo en Área o Empresa mientras sus activos tengan asignaciones operativas',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRole === AssetTypeOperationalRole.AREA) {
|
||||
const [used] = (await manager.query(
|
||||
`SELECT (
|
||||
(SELECT COUNT(*) FROM area_company_relations relation
|
||||
INNER JOIN assets area ON area.id = relation.area_id
|
||||
WHERE area.asset_type_id = $1)
|
||||
+
|
||||
(SELECT COUNT(*) FROM assets asset
|
||||
INNER JOIN assets area ON area.id = asset.operational_area_id
|
||||
WHERE area.asset_type_id = $1)
|
||||
)::integer AS count`,
|
||||
[typeId],
|
||||
)) as Array<{ count: number }>;
|
||||
if (Number(used?.count ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
|
||||
message: 'El tipo todavía está utilizado como Área en relaciones o asignaciones operativas',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRole === AssetTypeOperationalRole.COMPANY) {
|
||||
const [used] = (await manager.query(
|
||||
`SELECT (
|
||||
(SELECT COUNT(*) FROM area_company_relations relation
|
||||
INNER JOIN assets company ON company.id = relation.company_id
|
||||
WHERE company.asset_type_id = $1)
|
||||
+
|
||||
(SELECT COUNT(*) FROM assets asset
|
||||
INNER JOIN assets company ON company.id = asset.operator_company_id
|
||||
WHERE company.asset_type_id = $1)
|
||||
)::integer AS count`,
|
||||
[typeId],
|
||||
)) as Array<{ count: number }>;
|
||||
if (Number(used?.count ?? 0) > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_TYPE_OPERATIONAL_ROLE_IN_USE',
|
||||
message: 'El tipo todavía está utilizado como Empresa en relaciones o asignaciones operativas',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async loadView(manager: EntityManager, id: string): Promise<AssetTypeView> {
|
||||
const rows = (await manager.query(this.viewQuery('WHERE asset_type.id = $1'), [id])) as AssetTypeView[];
|
||||
if (!rows[0]) throw typeNotFound();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private viewQuery(where: string): string {
|
||||
return `
|
||||
SELECT
|
||||
asset_type.id,
|
||||
asset_type.code,
|
||||
asset_type.name,
|
||||
asset_type.description,
|
||||
asset_type.can_be_root AS "canBeRoot",
|
||||
asset_type.is_active AS "isActive",
|
||||
asset_type.operational_role AS "operationalRole",
|
||||
asset_type.created_at AS "createdAt",
|
||||
asset_type.updated_at AS "updatedAt",
|
||||
(SELECT COUNT(*)::integer FROM assets WHERE asset_type_id = asset_type.id) AS "assetCount",
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id', parent_type.id,
|
||||
'code', parent_type.code,
|
||||
'name', parent_type.name,
|
||||
'isActive', parent_type.is_active,
|
||||
'operationalRole', parent_type.operational_role
|
||||
) ORDER BY parent_type.name)
|
||||
FROM asset_type_parent_rules rule
|
||||
INNER JOIN asset_types parent_type ON parent_type.id = rule.parent_type_id
|
||||
WHERE rule.child_type_id = asset_type.id
|
||||
), '[]'::jsonb) AS "allowedParentTypes",
|
||||
COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id', definition.id,
|
||||
'code', definition.code,
|
||||
'name', definition.name,
|
||||
'dataType', definition.data_type,
|
||||
'isRequired', definition.is_required,
|
||||
'isActive', definition.is_active,
|
||||
'unit', definition.unit,
|
||||
'options', definition.options,
|
||||
'sortOrder', definition.sort_order
|
||||
) ORDER BY definition.sort_order, definition.name)
|
||||
FROM asset_attribute_definitions definition
|
||||
WHERE definition.asset_type_id = asset_type.id
|
||||
), '[]'::jsonb) AS attributes
|
||||
FROM asset_types asset_type
|
||||
${where}
|
||||
ORDER BY asset_type.is_active DESC, asset_type.name ASC
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
const TRACKED_FIELDS = [
|
||||
'code',
|
||||
'name',
|
||||
'commonName',
|
||||
'description',
|
||||
'type',
|
||||
'parent',
|
||||
'operationalArea',
|
||||
'operatorCompany',
|
||||
'informationStatus',
|
||||
'operationalStatus',
|
||||
'organizationProfile',
|
||||
'organizationMemberships',
|
||||
'externalIdentifiers',
|
||||
'sourceDocuments',
|
||||
'legalRights',
|
||||
'attributes',
|
||||
'geometry',
|
||||
'media',
|
||||
'provenance',
|
||||
] as const;
|
||||
|
||||
export function changedSnapshotFields(
|
||||
previous: Record<string, unknown> | null,
|
||||
current: Record<string, unknown>,
|
||||
): string[] {
|
||||
if (!previous) {
|
||||
return TRACKED_FIELDS.filter((field) => {
|
||||
const value = current[field];
|
||||
return value !== null && value !== undefined && !(
|
||||
Array.isArray(value) && value.length === 0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return TRACKED_FIELDS.filter(
|
||||
(field) => !isDeepStrictEqual(previous[field], current[field]),
|
||||
);
|
||||
}
|
||||
@@ -1,197 +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 { AssetsService } from './assets.service';
|
||||
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||
import { ChangeAssetStatusDto } from './dto/change-asset-status.dto';
|
||||
import { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto';
|
||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||
import { CreateFieldDiscoveryDto } from './dto/create-field-discovery.dto';
|
||||
import { ListFieldDiscoveriesQueryDto } from './dto/list-field-discoveries-query.dto';
|
||||
import { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto';
|
||||
import { ListAssetsQueryDto } from './dto/list-assets-query.dto';
|
||||
import { ListAssetTreeQueryDto } from './dto/list-asset-tree-query.dto';
|
||||
import { ListAssetTreeChildrenQueryDto } from './dto/list-asset-tree-children-query.dto';
|
||||
import { ParentOptionsQueryDto } from './dto/parent-options-query.dto';
|
||||
import { UpdateAssetDto } from './dto/update-asset.dto';
|
||||
import { ChangeAssetContextDto } from './dto/change-asset-context.dto';
|
||||
|
||||
@Controller('assets')
|
||||
export class AssetsController {
|
||||
constructor(
|
||||
private readonly assets: AssetsService,
|
||||
private readonly mergedDossier: MergedInventoryDossierService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read')
|
||||
list(@Query() query: ListAssetsQueryDto) {
|
||||
return this.assets.list(query);
|
||||
}
|
||||
|
||||
@Get('field-discoveries')
|
||||
@RequirePermissions('assets.read')
|
||||
listFieldDiscoveries(@Query() query: ListFieldDiscoveriesQueryDto) {
|
||||
return this.assets.listFieldDiscoveries(query);
|
||||
}
|
||||
|
||||
@Post('field-discoveries')
|
||||
@RequirePermissions('assets.create')
|
||||
createFieldDiscovery(
|
||||
@Body() dto: CreateFieldDiscoveryDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assets.createFieldDiscovery(dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('field-discoveries/:id/approve')
|
||||
@RequirePermissions('assets.change_status')
|
||||
approveFieldDiscovery(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ReviewFieldDiscoveryDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assets.approveFieldDiscovery(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('field-discoveries/:id/reject')
|
||||
@RequirePermissions('assets.change_status')
|
||||
rejectFieldDiscovery(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: RejectFieldDiscoveryDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assets.rejectFieldDiscovery(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('field-discoveries/:id/match')
|
||||
@RequirePermissions('assets.change_status')
|
||||
matchFieldDiscovery(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: MatchFieldDiscoveryDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assets.matchFieldDiscovery(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Get('tree')
|
||||
@RequirePermissions('assets.read')
|
||||
tree(@Query() query: ListAssetTreeQueryDto) {
|
||||
return this.assets.tree(query);
|
||||
}
|
||||
|
||||
@Get('tree-children')
|
||||
@RequirePermissions('assets.read')
|
||||
treeChildren(@Query() query: ListAssetTreeChildrenQueryDto) {
|
||||
return this.assets.treeChildren(query);
|
||||
}
|
||||
|
||||
@Get('parent-options')
|
||||
@RequirePermissions('assets.read')
|
||||
parentOptions(@Query() query: ParentOptionsQueryDto) {
|
||||
return this.assets.parentOptions(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('assets.create')
|
||||
create(
|
||||
@Body() dto: CreateAssetDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assets.create(dto, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id/lineage')
|
||||
@RequirePermissions('assets.read')
|
||||
lineage(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.assets.lineage(id);
|
||||
}
|
||||
|
||||
@Get(':id/dossier')
|
||||
@RequirePermissions(
|
||||
'assets.read',
|
||||
'inspections.read',
|
||||
'inspection_acts.read',
|
||||
'inspection_findings.read',
|
||||
'inspection_evidence.read',
|
||||
'inspection_communications.read',
|
||||
)
|
||||
dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise<unknown> {
|
||||
return this.mergedDossier.dossier(id);
|
||||
}
|
||||
|
||||
@Get(':id/context-history')
|
||||
@RequirePermissions('assets.read_history')
|
||||
contextHistory(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.assets.contextHistory(id);
|
||||
}
|
||||
|
||||
@Post(':id/context')
|
||||
@RequirePermissions('assets.manage_context')
|
||||
changeContext(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ChangeAssetContextDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assets.changeContext(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('assets.read')
|
||||
getById(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.assets.getById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('assets.update')
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: UpdateAssetDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assets.update(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Patch(':id/operational-status')
|
||||
@RequirePermissions('assets.change_operational_status')
|
||||
changeOperationalStatus(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ChangeAssetOperationalStatusDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assets.changeOperationalStatus(id, dto, principal, request);
|
||||
}
|
||||
|
||||
@Patch(':id/information-status')
|
||||
@RequirePermissions('assets.change_status')
|
||||
changeStatus(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Body() dto: ChangeAssetStatusDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.assets.changeStatus(id, dto, principal, request);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user