Compare commits
194
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29d541a274 | ||
|
|
d9a23c4c80 | ||
|
|
f2cb276785 | ||
|
|
37e4b840d9 | ||
|
|
463734f5b0 | ||
|
|
f334f2baaa | ||
|
|
e689ab57ec | ||
|
|
e54fa4503a | ||
|
|
810ac860c7 | ||
|
|
a4b5bdd3a9 | ||
|
|
a02a2dc4a8 | ||
|
|
9455254e6a | ||
|
|
7329cee080 | ||
|
|
76b688ac82 | ||
|
|
5fc239c664 | ||
|
|
6f335ab482 | ||
|
|
a521d0fec7 | ||
|
|
48b6962d93 | ||
|
|
aa26dc2227 | ||
|
|
0e14157a44 | ||
|
|
2acc3e095b | ||
|
|
9d5da0522d | ||
|
|
e443031bda | ||
|
|
3dba89f55a | ||
|
|
dcc2e56b3e | ||
|
|
51a2789bbd | ||
|
|
1212a21ea3 | ||
|
|
e296f18ed1 | ||
|
|
782b0150c5 | ||
|
|
f92611eaa1 | ||
|
|
e7a3faa912 | ||
|
|
3478c98f11 | ||
|
|
9e58a5fa9e | ||
|
|
bd71e74df6 | ||
|
|
636431f936 | ||
|
|
dafb57d706 | ||
|
|
a450665a6d | ||
|
|
9645d29ad8 | ||
|
|
f5b8ae38e3 | ||
|
|
b996a979d6 | ||
|
|
ebc4c303e4 | ||
|
|
5d066d1819 | ||
|
|
ff5cf080e7 | ||
|
|
4e2c7f85ef | ||
|
|
9ece0c1100 | ||
|
|
066b387f2b | ||
|
|
a3fbc17cb3 | ||
|
|
fd94200fc5 | ||
|
|
8896e8d183 | ||
|
|
516b310c48 | ||
|
|
08291acc8d | ||
|
|
7d041dd3b2 | ||
|
|
34dcd68482 | ||
|
|
a76da6787c | ||
|
|
3ce85d8d2e | ||
|
|
e5b522b667 | ||
|
|
31fc355e98 | ||
|
|
af6ccfee4c | ||
|
|
8582274643 | ||
|
|
6102740647 | ||
|
|
dec8908bbe | ||
|
|
4902e97b51 | ||
|
|
d5bd5777f1 | ||
|
|
f34d6fe521 | ||
|
|
5d2fec0f25 | ||
|
|
0f94a20298 | ||
|
|
f3af297a71 | ||
|
|
765d48f694 | ||
|
|
0f2885c562 | ||
|
|
ab3e699926 | ||
|
|
90c049a3c6 | ||
|
|
63528b1eff | ||
|
|
344699ac75 | ||
|
|
620e6f07c4 | ||
|
|
14d9467c0e | ||
|
|
4f1e24ef83 | ||
|
|
c4796adb39 | ||
|
|
f387645ff9 | ||
|
|
da2598c03d | ||
|
|
bdaccb66d0 | ||
|
|
f5f6afe797 | ||
|
|
c524d2ade6 | ||
|
|
a2ed3cf091 | ||
|
|
c4c6dd6c24 | ||
|
|
cb69cea3a9 | ||
|
|
721a189b4c | ||
|
|
6895edce20 | ||
|
|
97f5fbdd9e | ||
|
|
1a89024117 | ||
|
|
f9fd66d913 | ||
|
|
2143b9454f | ||
|
|
d765f7d65e | ||
|
|
c5dde72b7f | ||
|
|
3988918023 | ||
|
|
acb2488025 | ||
|
|
a49171fe31 | ||
|
|
1f5040f3c5 | ||
|
|
958a4d134d | ||
|
|
e7dd2a7284 | ||
|
|
dd4199cd91 | ||
|
|
299a637901 | ||
|
|
1334611ebc | ||
|
|
f82de44ded | ||
|
|
dd8ea8539b | ||
|
|
1c5efa8806 | ||
|
|
29c1abf907 | ||
|
|
7b5050e08e | ||
|
|
5c0b8ed786 | ||
|
|
ba4752a700 | ||
|
|
cf3327a4da | ||
|
|
e09930d411 | ||
|
|
012703336a | ||
|
|
5f5c790301 | ||
|
|
6ed0ce4731 | ||
|
|
f78781ed9a | ||
|
|
60ecfff794 | ||
|
|
3db62aad77 | ||
|
|
83efd2cac2 | ||
|
|
e5b7d72951 | ||
|
|
f9cc561098 | ||
|
|
7673afcc4f | ||
|
|
30a06f17f3 | ||
|
|
f8da455241 | ||
|
|
0103422473 | ||
|
|
cd42e55b7b | ||
|
|
205b97f16b | ||
|
|
118e41c938 | ||
|
|
00ac09f358 | ||
|
|
199129847e | ||
|
|
216a813f50 | ||
|
|
ad79fb589d | ||
|
|
0b2c7f0216 | ||
|
|
2a7bf54ce1 | ||
|
|
692e6119cc | ||
|
|
b3d6ae1326 | ||
|
|
0d181b37a0 | ||
|
|
db3d87727e | ||
|
|
5b708e433f | ||
|
|
3d101e239c | ||
|
|
3a0c37b2a7 | ||
|
|
4c01cf6ffe | ||
|
|
66333fb5dd | ||
|
|
06f809b4c8 | ||
|
|
343c889250 | ||
|
|
4fb1c502c8 | ||
|
|
d895812235 | ||
|
|
f680764373 | ||
|
|
acc17bf291 | ||
|
|
1b57dcc850 | ||
|
|
77d750992d | ||
|
|
b30cc1a294 | ||
|
|
a7a6680cf8 | ||
|
|
44570ad457 | ||
|
|
8caed06362 | ||
|
|
e3db3d6cd7 | ||
|
|
fb7b29cd94 | ||
|
|
0a11a452b5 | ||
|
|
1d661e2e48 | ||
|
|
2ee956665f | ||
|
|
0d5db42c77 | ||
|
|
21127b8cff | ||
|
|
50a9dc5183 | ||
|
|
a69df1d2d6 | ||
|
|
95f34e2f08 | ||
|
|
2f0d0d5ffc | ||
|
|
d2c79aeddc | ||
|
|
aef5a3d73d | ||
|
|
1a51bdf820 | ||
|
|
2142331fc0 | ||
|
|
f88dc8c2e0 | ||
|
|
6acbb26a82 | ||
|
|
56e5c9b59e | ||
|
|
685b561821 | ||
|
|
ec600a1aff | ||
|
|
823e905aa3 | ||
|
|
0b8e673072 | ||
|
|
2c45eb7485 | ||
|
|
3f6c537f05 | ||
|
|
aed83e9ad7 | ||
|
|
e35ee78b08 | ||
|
|
250540e076 | ||
|
|
c527bc57dc | ||
|
|
9da2f85c3c | ||
|
|
0c309036cb | ||
|
|
6bda0f68fc | ||
|
|
6a86f67eba | ||
|
|
37795e6586 | ||
|
|
3cd9cdd104 | ||
|
|
205ff1bab8 | ||
|
|
d954ec135f | ||
|
|
4c5742c548 | ||
|
|
cd24736df8 | ||
|
|
8e2d0dfe19 | ||
|
|
afde214cbb |
@@ -0,0 +1,63 @@
|
|||||||
|
name: Android APK
|
||||||
|
# F3.1: este workflow genera la APK debug verificable de la rama antes de promoverla.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'feature/f2-2*'
|
||||||
|
- 'feature/f2-3*'
|
||||||
|
- 'feature/f2-4*'
|
||||||
|
- 'feature/f3-1*'
|
||||||
|
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.1-0.11.0-debug
|
||||||
|
path: android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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 = 18
|
||||||
|
versionName = "0.11.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
@@ -0,0 +1 @@
|
|||||||
|
# DH Inspección V2. Las reglas se ampliarán cuando se habilite minificación de release.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
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.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)
|
||||||
|
|
||||||
|
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 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
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeVisitView() {
|
||||||
|
visit = null
|
||||||
|
inventory = emptyList()
|
||||||
|
fieldTypes = emptyList()
|
||||||
|
selectedFieldAsset = null
|
||||||
|
clearFindingState()
|
||||||
|
loadVisits()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startVisit() {
|
||||||
|
val id = visit?.id ?: return
|
||||||
|
launchBusy {
|
||||||
|
visit = repository.startVisit(id)
|
||||||
|
notice = "Inspección iniciada."
|
||||||
|
loadVisitsInternal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||||
|
loadFindingOptionsInternal(visitId, item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||||
|
loadFindingOptionsInternal(visitId, result.canonical.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
|
||||||
|
if (response.capture.readyForFinding) {
|
||||||
|
loadFindingOptionsInternal(visitId, asset.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openFindingForSelected() {
|
||||||
|
val visitId = visit?.id ?: return
|
||||||
|
val assetId = selectedFieldAsset?.asset?.id ?: return
|
||||||
|
launchBusy { loadFindingOptionsInternal(visitId, assetId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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 {
|
||||||
|
val response = findingsRepository.create(
|
||||||
|
visitId,
|
||||||
|
assetId,
|
||||||
|
CreateFieldFindingRequest(
|
||||||
|
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. Podés agregar evidencia fotográfica."
|
||||||
|
loadFindingOptionsInternal(visitId, assetId, keepLastCreated = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 clearFindingFlow() {
|
||||||
|
fieldFindingOptions = null
|
||||||
|
lastCreatedFinding = null
|
||||||
|
fieldFindingEvidence = emptyMap()
|
||||||
|
error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSelectedFieldAsset() {
|
||||||
|
selectedFieldAsset = null
|
||||||
|
clearFindingState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadFindingOptionsInternal(
|
||||||
|
visitId: String,
|
||||||
|
assetId: String,
|
||||||
|
keepLastCreated: Boolean = false,
|
||||||
|
) {
|
||||||
|
val options = findingsRepository.options(visitId, assetId)
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
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 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 catalog: FieldFindingCatalog,
|
||||||
|
val findings: List<FieldFindingItem> = emptyList(),
|
||||||
|
val canAddAnother: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateFieldFindingRequest(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
): 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.
|
||||||
|
* Comparte el almacén cifrado de sesión y nunca persiste la contraseña.
|
||||||
|
*/
|
||||||
|
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): FieldFindingOptionsResponse =
|
||||||
|
authorized { session ->
|
||||||
|
api.options("Bearer ${session.accessToken}", visitId, assetId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
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) }
|
||||||
|
if (inventoryMode) {
|
||||||
|
F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
|
||||||
|
} else {
|
||||||
|
F3VisitOverview(model, onInventory = { inventoryMode = true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun F3VisitOverview(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(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")
|
||||||
|
}
|
||||||
|
} else if (visit.status == "PLANNED") {
|
||||||
|
Text(
|
||||||
|
"Primero iniciá la inspección para habilitar altas, fotografías 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)
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
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
@@ -0,0 +1,30 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">DH Inspección</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "DHInspeccion"
|
||||||
|
include(":app")
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.19.6-4",
|
"version": "0.20.0-2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.19.6-4",
|
"version": "0.20.0-2",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.19.6-4",
|
"version": "0.24.0-1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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 {}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
|
||||||
|
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))) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const optionalText = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@Transform(({ value }) =>
|
@Transform(({ value }) =>
|
||||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
@@ -23,13 +26,38 @@ export class CreateUserDto {
|
|||||||
username!: string;
|
username!: string;
|
||||||
|
|
||||||
@Transform(({ value }) =>
|
@Transform(({ value }) =>
|
||||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
|
||||||
)
|
)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
@MaxLength(320)
|
@MaxLength(320)
|
||||||
email?: string | null;
|
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()
|
@IsString()
|
||||||
@MinLength(12)
|
@MinLength(12)
|
||||||
@MaxLength(128)
|
@MaxLength(128)
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const optionalText = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
|
||||||
export class UpdateUserDto {
|
export class UpdateUserDto {
|
||||||
@Transform(({ value }) =>
|
@Transform(({ value }) =>
|
||||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
||||||
@@ -20,13 +23,38 @@ export class UpdateUserDto {
|
|||||||
username?: string;
|
username?: string;
|
||||||
|
|
||||||
@Transform(({ value }) =>
|
@Transform(({ value }) =>
|
||||||
typeof value === 'string' ? value.trim().toLowerCase() : value,
|
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
|
||||||
)
|
)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
@MaxLength(320)
|
@MaxLength(320)
|
||||||
email?: string | null;
|
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))
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export interface AdministrativeUserView {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
dni: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
jobTitle: string | null;
|
||||||
|
employeeNumber: string | null;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
@@ -72,6 +76,13 @@ function roleSelectionInvalid(): BadRequestException {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -96,6 +107,10 @@ export class UsersService {
|
|||||||
OR user_account.email ILIKE $${parameters.length}
|
OR user_account.email ILIKE $${parameters.length}
|
||||||
OR user_account.first_name ILIKE $${parameters.length}
|
OR user_account.first_name ILIKE $${parameters.length}
|
||||||
OR user_account.last_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}
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -115,6 +130,10 @@ export class UsersService {
|
|||||||
user_account.id,
|
user_account.id,
|
||||||
user_account.username,
|
user_account.username,
|
||||||
user_account.email,
|
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.first_name AS "firstName",
|
||||||
user_account.last_name AS "lastName",
|
user_account.last_name AS "lastName",
|
||||||
user_account.status,
|
user_account.status,
|
||||||
@@ -176,9 +195,14 @@ export class UsersService {
|
|||||||
try {
|
try {
|
||||||
return await this.dataSource.transaction(async (manager) => {
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
const roles = await this.resolveRoles(manager, dto.roleIds);
|
const roles = await this.resolveRoles(manager, dto.roleIds);
|
||||||
|
this.assertInspectorHasEmail(roles, dto.email ?? null);
|
||||||
const user = manager.getRepository(User).create({
|
const user = manager.getRepository(User).create({
|
||||||
username: dto.username.trim().toLowerCase(),
|
username: dto.username.trim().toLowerCase(),
|
||||||
email: dto.email?.trim().toLowerCase() || null,
|
email: dto.email?.trim().toLowerCase() || null,
|
||||||
|
dni: dto.dni ?? null,
|
||||||
|
phone: dto.phone ?? null,
|
||||||
|
jobTitle: dto.jobTitle ?? null,
|
||||||
|
employeeNumber: dto.employeeNumber ?? null,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
firstName: dto.firstName.trim(),
|
firstName: dto.firstName.trim(),
|
||||||
lastName: dto.lastName.trim(),
|
lastName: dto.lastName.trim(),
|
||||||
@@ -227,6 +251,10 @@ export class UsersService {
|
|||||||
if (
|
if (
|
||||||
dto.username === undefined &&
|
dto.username === undefined &&
|
||||||
dto.email === undefined &&
|
dto.email === undefined &&
|
||||||
|
dto.dni === undefined &&
|
||||||
|
dto.phone === undefined &&
|
||||||
|
dto.jobTitle === undefined &&
|
||||||
|
dto.employeeNumber === undefined &&
|
||||||
dto.firstName === undefined &&
|
dto.firstName === undefined &&
|
||||||
dto.lastName === undefined
|
dto.lastName === undefined
|
||||||
) {
|
) {
|
||||||
@@ -240,6 +268,9 @@ export class UsersService {
|
|||||||
return await this.dataSource.transaction(async (manager) => {
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
const user = await this.lockUser(manager, id);
|
const user = await this.lockUser(manager, id);
|
||||||
const before = await this.loadUserView(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) {
|
if (dto.username !== undefined) {
|
||||||
user.username = dto.username.trim().toLowerCase();
|
user.username = dto.username.trim().toLowerCase();
|
||||||
@@ -247,6 +278,10 @@ export class UsersService {
|
|||||||
if (dto.email !== undefined) {
|
if (dto.email !== undefined) {
|
||||||
user.email = dto.email?.trim().toLowerCase() || null;
|
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.firstName !== undefined) user.firstName = dto.firstName.trim();
|
||||||
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
|
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
|
||||||
user.updatedBy = principal.userId;
|
user.updatedBy = principal.userId;
|
||||||
@@ -379,6 +414,7 @@ export class UsersService {
|
|||||||
await this.lockUser(manager, id);
|
await this.lockUser(manager, id);
|
||||||
const roles = await this.resolveRoles(manager, dto.roleIds);
|
const roles = await this.resolveRoles(manager, dto.roleIds);
|
||||||
const before = await this.loadUserView(manager, id);
|
const before = await this.loadUserView(manager, id);
|
||||||
|
this.assertInspectorHasEmail(roles, before.email);
|
||||||
const beforeIds = before.roles.map((role) => role.id).sort();
|
const beforeIds = before.roles.map((role) => role.id).sort();
|
||||||
const afterIds = roles.map((role) => role.id).sort();
|
const afterIds = roles.map((role) => role.id).sort();
|
||||||
if (beforeIds.join(',') === afterIds.join(',')) return before;
|
if (beforeIds.join(',') === afterIds.join(',')) return before;
|
||||||
@@ -417,6 +453,12 @@ export class UsersService {
|
|||||||
return roles;
|
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(
|
private async insertUserRoles(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -455,6 +497,10 @@ export class UsersService {
|
|||||||
user_account.id,
|
user_account.id,
|
||||||
user_account.username,
|
user_account.username,
|
||||||
user_account.email,
|
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.first_name AS "firstName",
|
||||||
user_account.last_name AS "lastName",
|
user_account.last_name AS "lastName",
|
||||||
user_account.status,
|
user_account.status,
|
||||||
@@ -491,7 +537,7 @@ export class UsersService {
|
|||||||
private userConflict(): ConflictException {
|
private userConflict(): ConflictException {
|
||||||
return new ConflictException({
|
return new ConflictException({
|
||||||
code: 'USER_ALREADY_EXISTS',
|
code: 'USER_ALREADY_EXISTS',
|
||||||
message: 'El usuario o email ya está registrado',
|
message: 'El usuario, email o DNI ya está registrado',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { InspectionClosingModule } from './inspection-closing/inspection-closing
|
|||||||
import { AssetImportsModule } from './asset-imports/asset-imports.module';
|
import { AssetImportsModule } from './asset-imports/asset-imports.module';
|
||||||
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
|
import { InspectionReportsModule } from './inspection-reports/inspection-reports.module';
|
||||||
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
|
import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module';
|
||||||
|
import { ActAdministrationModule } from './act-administration/act-administration.module';
|
||||||
|
|
||||||
function required(config: ConfigService, key: string): string {
|
function required(config: ConfigService, key: string): string {
|
||||||
const value = config.get<string>(key);
|
const value = config.get<string>(key);
|
||||||
@@ -76,6 +77,7 @@ function required(config: ConfigService, key: string): string {
|
|||||||
InspectionClosingModule,
|
InspectionClosingModule,
|
||||||
InspectionReportsModule,
|
InspectionReportsModule,
|
||||||
InspectionVerificationsModule,
|
InspectionVerificationsModule,
|
||||||
|
ActAdministrationModule,
|
||||||
AssetImportsModule,
|
AssetImportsModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
|
|||||||
@@ -22,12 +22,23 @@ import { AssetOperationalRelationsService } from './asset-operational-relations.
|
|||||||
import { AssetRegistryController } from './asset-registry.controller';
|
import { AssetRegistryController } from './asset-registry.controller';
|
||||||
import { AssetRegistryService } from './asset-registry.service';
|
import { AssetRegistryService } from './asset-registry.service';
|
||||||
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
||||||
|
import { InventoryStructureController } from './inventory-structure.controller';
|
||||||
|
import { 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({
|
@Module({
|
||||||
imports: [AuditModule],
|
imports: [AuditModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
AssetTypesController,
|
AssetTypesController,
|
||||||
AssetsController,
|
AssetsController,
|
||||||
|
InventoryStructureController,
|
||||||
|
InventoryFamilyCatalogController,
|
||||||
|
InventoryMergeController,
|
||||||
|
FieldInventoryMergeController,
|
||||||
AssetGeometriesController,
|
AssetGeometriesController,
|
||||||
MapAssetsController,
|
MapAssetsController,
|
||||||
AssetHistoryController,
|
AssetHistoryController,
|
||||||
@@ -40,6 +51,10 @@ import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/fi
|
|||||||
providers: [
|
providers: [
|
||||||
AssetTypesService,
|
AssetTypesService,
|
||||||
AssetsService,
|
AssetsService,
|
||||||
|
InventoryStructureService,
|
||||||
|
InventoryFamilyCatalogService,
|
||||||
|
InventoryMergeService,
|
||||||
|
MergedInventoryDossierService,
|
||||||
AssetGeometriesService,
|
AssetGeometriesService,
|
||||||
AssetHistoryService,
|
AssetHistoryService,
|
||||||
AssetMediaService,
|
AssetMediaService,
|
||||||
@@ -49,6 +64,14 @@ import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/fi
|
|||||||
AssetRegistryService,
|
AssetRegistryService,
|
||||||
FieldDiscoveryInspectionLinkService,
|
FieldDiscoveryInspectionLinkService,
|
||||||
],
|
],
|
||||||
exports: [AssetHistoryService],
|
exports: [
|
||||||
|
AssetHistoryService,
|
||||||
|
AssetsService,
|
||||||
|
InventoryMergeService,
|
||||||
|
MergedInventoryDossierService,
|
||||||
|
AssetGeometriesService,
|
||||||
|
AssetMediaService,
|
||||||
|
FieldDiscoveryInspectionLinkService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AssetMasterModule {}
|
export class AssetMasterModule {}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
RequestWithContext,
|
RequestWithContext,
|
||||||
} from '../common/http/request-context';
|
} from '../common/http/request-context';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
|
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||||
import { ChangeAssetStatusDto } from './dto/change-asset-status.dto';
|
import { ChangeAssetStatusDto } from './dto/change-asset-status.dto';
|
||||||
import { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto';
|
import { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto';
|
||||||
import { CreateAssetDto } from './dto/create-asset.dto';
|
import { CreateAssetDto } from './dto/create-asset.dto';
|
||||||
@@ -31,7 +32,10 @@ import { ChangeAssetContextDto } from './dto/change-asset-context.dto';
|
|||||||
|
|
||||||
@Controller('assets')
|
@Controller('assets')
|
||||||
export class AssetsController {
|
export class AssetsController {
|
||||||
constructor(private readonly assets: AssetsService) {}
|
constructor(
|
||||||
|
private readonly assets: AssetsService,
|
||||||
|
private readonly mergedDossier: MergedInventoryDossierService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions('assets.read')
|
@RequirePermissions('assets.read')
|
||||||
@@ -131,8 +135,8 @@ export class AssetsController {
|
|||||||
'inspection_evidence.read',
|
'inspection_evidence.read',
|
||||||
'inspection_communications.read',
|
'inspection_communications.read',
|
||||||
)
|
)
|
||||||
dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
dossier(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string): Promise<unknown> {
|
||||||
return this.assets.dossier(id);
|
return this.mergedDossier.dossier(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/context-history')
|
@Get(':id/context-history')
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export class CreateAssetDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
inventoryFamilyId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
operationalAreaId?: string | null;
|
operationalAreaId?: string | null;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export const INVENTORY_STRUCTURE_KINDS = [
|
||||||
|
'AREA',
|
||||||
|
'YACIMIENTO',
|
||||||
|
'INSTALACION',
|
||||||
|
'SUBINSTALACION',
|
||||||
|
] as const;
|
||||||
|
export type InventoryStructureKind = typeof INVENTORY_STRUCTURE_KINDS[number];
|
||||||
|
|
||||||
|
export class CreateInventoryStructureDto {
|
||||||
|
@IsIn(INVENTORY_STRUCTURE_KINDS)
|
||||||
|
kind!: InventoryStructureKind;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toUpperCase() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||||
|
code?: string | null;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
commonName?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
familyId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class MergeInventoryAssetDto {
|
||||||
|
@IsUUID('4')
|
||||||
|
canonicalAssetId!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(2000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||||
|
|
||||||
|
@Controller('inventory-families')
|
||||||
|
export class InventoryFamilyCatalogController {
|
||||||
|
constructor(private readonly families: InventoryFamilyCatalogService) {}
|
||||||
|
|
||||||
|
@Get(':familyId/findings')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
|
||||||
|
return this.families.findings(familyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InventoryFamilyCatalogService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async findings(familyId: string) {
|
||||||
|
const [family] = await this.dataSource.query(`
|
||||||
|
SELECT id,code,name,level,information_labels AS "informationLabels"
|
||||||
|
FROM inventory_families
|
||||||
|
WHERE id=$1::uuid AND is_active=true
|
||||||
|
`, [familyId]) as Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
level: string;
|
||||||
|
informationLabels: string[];
|
||||||
|
}>;
|
||||||
|
if (!family) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INVENTORY_FAMILY_NOT_FOUND',
|
||||||
|
message: 'La familia técnica no existe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const items = await this.dataSource.query(`
|
||||||
|
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
|
||||||
|
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
|
||||||
|
category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName"
|
||||||
|
FROM finding_catalog_item_inventory_families mapping
|
||||||
|
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
WHERE mapping.inventory_family_id=$1::uuid
|
||||||
|
AND item.is_active=true AND category.is_active=true
|
||||||
|
ORDER BY category.sort_order,item.source_number,item.title
|
||||||
|
`, [familyId]);
|
||||||
|
return { family, items, count: items.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
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 { MergeInventoryAssetDto } from './dto/merge-inventory-asset.dto';
|
||||||
|
import { InventoryMergeService } from './inventory-merge.service';
|
||||||
|
|
||||||
|
@Controller('assets')
|
||||||
|
export class InventoryMergeController {
|
||||||
|
constructor(private readonly merges: InventoryMergeService) {}
|
||||||
|
|
||||||
|
@Get(':id/merge-status')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
status(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.merges.status(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/merge')
|
||||||
|
@RequirePermissions('assets.update')
|
||||||
|
merge(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: MergeInventoryAssetDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.merges.merge(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('inspection-visits/:visitId/field-inventory')
|
||||||
|
export class FieldInventoryMergeController {
|
||||||
|
constructor(private readonly merges: InventoryMergeService) {}
|
||||||
|
|
||||||
|
@Post(':assetId/merge')
|
||||||
|
@RequirePermissions('assets.create', 'inspections.execute')
|
||||||
|
mergeFieldDiscovery(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@Body() dto: MergeInventoryAssetDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.merges.mergeFromVisit(visitId, assetId, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
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 {
|
||||||
|
AssetVersionChangeType,
|
||||||
|
AuditAction,
|
||||||
|
} from '../database/entities';
|
||||||
|
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
||||||
|
import type { MergeInventoryAssetDto } from './dto/merge-inventory-asset.dto';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
|
||||||
|
type MergeableAssetRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
typeCode: string;
|
||||||
|
parentId: string | null;
|
||||||
|
operationalAreaId: string | null;
|
||||||
|
operatorCompanyId: string | null;
|
||||||
|
inventoryFamilyId: string | null;
|
||||||
|
informationStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MergeRow = {
|
||||||
|
id: string;
|
||||||
|
sourceAssetId: string;
|
||||||
|
canonicalAssetId: string;
|
||||||
|
reason: string;
|
||||||
|
mergedAt: Date;
|
||||||
|
mergedBy: string | null;
|
||||||
|
source: string;
|
||||||
|
requestId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InventoryMergeService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly history: AssetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async status(assetId: string) {
|
||||||
|
const requested = await this.loadAsset(this.dataSource.manager, assetId, false);
|
||||||
|
const canonicalId = await this.resolveCanonicalId(this.dataSource.manager, assetId);
|
||||||
|
const canonical = canonicalId === assetId
|
||||||
|
? requested
|
||||||
|
: await this.loadAsset(this.dataSource.manager, canonicalId, false);
|
||||||
|
const chain = await this.mergeChain(assetId);
|
||||||
|
const aliases = await this.aliasesFor(canonicalId);
|
||||||
|
return {
|
||||||
|
requested,
|
||||||
|
isMerged: canonicalId !== assetId,
|
||||||
|
canonical,
|
||||||
|
chain,
|
||||||
|
aliases,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async merge(
|
||||||
|
sourceAssetId: string,
|
||||||
|
dto: MergeInventoryAssetDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.dataSource.transaction((manager) =>
|
||||||
|
this.mergeInTransaction(manager, sourceAssetId, dto, principal, request),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergeFromVisit(
|
||||||
|
visitId: string,
|
||||||
|
sourceAssetId: string,
|
||||||
|
dto: MergeInventoryAssetDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
assertMobileInspector(principal);
|
||||||
|
const [gate] = (await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
visit.status,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM inspection_visit_members member
|
||||||
|
WHERE member.visit_id=visit.id
|
||||||
|
AND member.user_id=$3::uuid
|
||||||
|
AND member.included=true
|
||||||
|
) OR visit.lead_inspector_user_id=$3::uuid AS assigned,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM asset_field_discoveries discovery
|
||||||
|
WHERE discovery.visit_id=visit.id
|
||||||
|
AND discovery.asset_id=$2::uuid
|
||||||
|
) AS "createdInVisit"
|
||||||
|
FROM inspection_visits visit
|
||||||
|
WHERE visit.id=$1::uuid
|
||||||
|
`, [visitId, sourceAssetId, principal.userId])) as Array<{
|
||||||
|
status: string;
|
||||||
|
assigned: boolean;
|
||||||
|
createdInVisit: boolean;
|
||||||
|
}>;
|
||||||
|
if (!gate) throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
|
||||||
|
if (gate.status !== 'IN_PROGRESS') throw new ConflictException({
|
||||||
|
code: 'FIELD_MERGE_VISIT_NOT_IN_PROGRESS',
|
||||||
|
message: 'Sólo se puede conciliar Inventario mientras la inspección está en curso',
|
||||||
|
});
|
||||||
|
if (!gate.assigned) throw new ConflictException({
|
||||||
|
code: 'FIELD_MERGE_INSPECTOR_NOT_ASSIGNED',
|
||||||
|
message: 'El inspector no está asignado a esta inspección',
|
||||||
|
});
|
||||||
|
if (!gate.createdInVisit) throw new ConflictException({
|
||||||
|
code: 'FIELD_MERGE_SOURCE_MUST_BE_DISCOVERY',
|
||||||
|
message: 'Desde la APK sólo se puede fusionar un alta nacida en esta inspección',
|
||||||
|
});
|
||||||
|
return this.merge(sourceAssetId, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mergeInTransaction(
|
||||||
|
manager: EntityManager,
|
||||||
|
sourceAssetId: string,
|
||||||
|
dto: MergeInventoryAssetDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const existing = await this.currentMerge(manager, sourceAssetId);
|
||||||
|
if (existing) throw new ConflictException({
|
||||||
|
code: 'INVENTORY_ALREADY_MERGED',
|
||||||
|
message: 'Este registro ya fue fusionado',
|
||||||
|
canonicalAssetId: existing.canonicalAssetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canonicalAssetId = await this.resolveCanonicalId(manager, dto.canonicalAssetId);
|
||||||
|
if (canonicalAssetId === sourceAssetId) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_MERGE_CYCLE',
|
||||||
|
message: 'La fusión generaría un ciclo entre registros',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = [sourceAssetId, canonicalAssetId].sort();
|
||||||
|
const locked = (await manager.query(`
|
||||||
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
||||||
|
asset.parent_id AS "parentId",
|
||||||
|
asset.operational_area_id AS "operationalAreaId",
|
||||||
|
asset.operator_company_id AS "operatorCompanyId",
|
||||||
|
asset.inventory_family_id AS "inventoryFamilyId",
|
||||||
|
asset.information_status AS "informationStatus"
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE asset.id=ANY($1::uuid[])
|
||||||
|
ORDER BY asset.id
|
||||||
|
FOR UPDATE OF asset
|
||||||
|
`, [ids])) as MergeableAssetRow[];
|
||||||
|
const source = locked.find((row) => row.id === sourceAssetId);
|
||||||
|
const canonical = locked.find((row) => row.id === canonicalAssetId);
|
||||||
|
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
||||||
|
|
||||||
|
this.validatePair(source, canonical);
|
||||||
|
const sourceParentCanonical = source.parentId
|
||||||
|
? await this.resolveCanonicalId(manager, source.parentId)
|
||||||
|
: null;
|
||||||
|
const targetParentCanonical = canonical.parentId
|
||||||
|
? await this.resolveCanonicalId(manager, canonical.parentId)
|
||||||
|
: null;
|
||||||
|
if (sourceParentCanonical !== targetParentCanonical) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_MERGE_PARENT_MISMATCH',
|
||||||
|
message: 'Los duplicados deben pertenecer al mismo padre estructural (considerando fusiones previas)',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (source.typeCode.toLowerCase() === 'instalacion') {
|
||||||
|
const invalidChildren = await manager.query(`
|
||||||
|
SELECT child.id,child.code,child.name
|
||||||
|
FROM assets child
|
||||||
|
WHERE child.parent_id=$1::uuid
|
||||||
|
AND child.information_status<>'INACTIVE'
|
||||||
|
AND (
|
||||||
|
child.inventory_family_id IS NULL
|
||||||
|
OR $2::uuid IS NULL
|
||||||
|
OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM inventory_family_parent_rules rule
|
||||||
|
WHERE rule.child_family_id=child.inventory_family_id
|
||||||
|
AND rule.parent_family_id=$2::uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY child.name,child.code
|
||||||
|
LIMIT 20
|
||||||
|
`, [source.id, canonical.inventoryFamilyId]) as Array<{ id: string; code: string; name: string }>;
|
||||||
|
if (invalidChildren.length > 0) throw new ConflictException({
|
||||||
|
code: 'INVENTORY_MERGE_CHILD_FAMILY_CONFLICT',
|
||||||
|
message: 'Hay Subinstalaciones que no son compatibles con la familia de la Instalación canónica',
|
||||||
|
data: invalidChildren,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
|
||||||
|
this.snapshot(manager, source.id),
|
||||||
|
this.snapshot(manager, canonical.id),
|
||||||
|
]);
|
||||||
|
const sourceLabel = principal.transport === 'bearer' ? 'ANDROID' : 'WEB';
|
||||||
|
const [mergeRecord] = (await manager.query(`
|
||||||
|
INSERT INTO asset_merges (
|
||||||
|
source_asset_id,canonical_asset_id,reason,merged_by,source,request_id,
|
||||||
|
source_snapshot,canonical_snapshot
|
||||||
|
) VALUES ($1::uuid,$2::uuid,$3::text,$4::uuid,$5::varchar,$6::varchar,$7::jsonb,$8::jsonb)
|
||||||
|
RETURNING id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
|
||||||
|
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
|
||||||
|
`, [
|
||||||
|
source.id,
|
||||||
|
canonical.id,
|
||||||
|
dto.reason,
|
||||||
|
principal.userId,
|
||||||
|
sourceLabel,
|
||||||
|
request.requestId,
|
||||||
|
sourceSnapshot,
|
||||||
|
canonicalSnapshot,
|
||||||
|
])) as MergeRow[];
|
||||||
|
|
||||||
|
const children = (await manager.query(`
|
||||||
|
SELECT id FROM assets
|
||||||
|
WHERE parent_id=$1::uuid AND information_status<>'INACTIVE'
|
||||||
|
ORDER BY id FOR UPDATE
|
||||||
|
`, [source.id])) as Array<{ id: string }>;
|
||||||
|
const reparentedChildIds: string[] = [];
|
||||||
|
for (const child of children) {
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE asset_context_history
|
||||||
|
SET valid_until=CURRENT_TIMESTAMP,
|
||||||
|
end_reason=$2::text,
|
||||||
|
ended_by=$3::uuid,
|
||||||
|
ended_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE asset_id=$1::uuid AND valid_until IS NULL
|
||||||
|
`, [child.id, `Reparentado por fusión cronológica ${source.code} → ${canonical.code}`, principal.userId]);
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE assets
|
||||||
|
SET parent_id=$2::uuid,updated_by=$3::uuid,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1::uuid
|
||||||
|
`, [child.id, canonical.id, principal.userId]);
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
child.id,
|
||||||
|
AssetVersionChangeType.UPDATED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO asset_context_history (
|
||||||
|
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
||||||
|
change_reason,asset_version_number,source,request_id,created_by
|
||||||
|
)
|
||||||
|
SELECT id,parent_id,operational_area_id,operator_company_id,CURRENT_TIMESTAMP,
|
||||||
|
$2::text,$3::integer,$4::varchar,$5::varchar,$6::uuid
|
||||||
|
FROM assets WHERE id=$1::uuid
|
||||||
|
`, [
|
||||||
|
child.id,
|
||||||
|
`Reparentado por fusión cronológica ${source.code} → ${canonical.code}`,
|
||||||
|
versionNumber,
|
||||||
|
sourceLabel,
|
||||||
|
request.requestId,
|
||||||
|
principal.userId,
|
||||||
|
]);
|
||||||
|
reparentedChildIds.push(child.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE assets
|
||||||
|
SET information_status='INACTIVE',updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1::uuid
|
||||||
|
`, [source.id, principal.userId]);
|
||||||
|
const sourceVersionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
source.id,
|
||||||
|
AssetVersionChangeType.STATUS_CHANGED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
merge: mergeRecord,
|
||||||
|
source: { id: source.id, code: source.code, name: source.name },
|
||||||
|
canonical: { id: canonical.id, code: canonical.code, name: canonical.name },
|
||||||
|
sourceVersionNumber,
|
||||||
|
reparentedChildIds,
|
||||||
|
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
|
||||||
|
};
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_UPDATED,
|
||||||
|
entityType: 'asset_merge',
|
||||||
|
entityId: mergeRecord.id,
|
||||||
|
beforeData: sourceSnapshot,
|
||||||
|
afterData: canonicalSnapshot,
|
||||||
|
metadata: {
|
||||||
|
operation: 'CHRONOLOGICAL_MERGE',
|
||||||
|
sourceAssetId: source.id,
|
||||||
|
canonicalAssetId: canonical.id,
|
||||||
|
reason: dto.reason,
|
||||||
|
sourceVersionNumber,
|
||||||
|
reparentedChildIds,
|
||||||
|
historicalReferencesRewritten: false,
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePair(source: MergeableAssetRow, canonical: MergeableAssetRow): void {
|
||||||
|
const sourceType = source.typeCode.toLowerCase();
|
||||||
|
const targetType = canonical.typeCode.toLowerCase();
|
||||||
|
if (!MERGEABLE_TYPES.has(sourceType) || sourceType !== targetType) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_MERGE_TYPE_INVALID',
|
||||||
|
message: 'Sólo se pueden fusionar Instalaciones entre sí o Subinstalaciones entre sí',
|
||||||
|
});
|
||||||
|
if (source.informationStatus === 'INACTIVE') throw new ConflictException({
|
||||||
|
code: 'INVENTORY_MERGE_SOURCE_INACTIVE',
|
||||||
|
message: 'El registro de origen ya está inactivo',
|
||||||
|
});
|
||||||
|
if (canonical.informationStatus === 'INACTIVE') throw new ConflictException({
|
||||||
|
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
|
||||||
|
message: 'El registro canónico no puede estar inactivo',
|
||||||
|
});
|
||||||
|
if (!source.operationalAreaId || !source.operatorCompanyId
|
||||||
|
|| source.operationalAreaId !== canonical.operationalAreaId
|
||||||
|
|| source.operatorCompanyId !== canonical.operatorCompanyId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH',
|
||||||
|
message: 'Los registros deben pertenecer a la misma Área y Operadora',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
|
||||||
|
const lockSql = lock ? 'FOR UPDATE OF asset' : '';
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
||||||
|
asset.parent_id AS "parentId",
|
||||||
|
asset.operational_area_id AS "operationalAreaId",
|
||||||
|
asset.operator_company_id AS "operatorCompanyId",
|
||||||
|
asset.inventory_family_id AS "inventoryFamilyId",
|
||||||
|
asset.information_status AS "informationStatus"
|
||||||
|
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE asset.id=$1::uuid ${lockSql}
|
||||||
|
`, [id])) as MergeableAssetRow[];
|
||||||
|
if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async currentMerge(manager: EntityManager, sourceAssetId: string): Promise<MergeRow | null> {
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
|
||||||
|
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
|
||||||
|
FROM asset_merges WHERE source_asset_id=$1::uuid
|
||||||
|
`, [sourceAssetId])) as MergeRow[];
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCanonicalId(manager: EntityManager, assetId: string): Promise<string> {
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
WITH RECURSIVE chain AS (
|
||||||
|
SELECT source_asset_id,canonical_asset_id,1 AS depth
|
||||||
|
FROM asset_merges WHERE source_asset_id=$1::uuid
|
||||||
|
UNION ALL
|
||||||
|
SELECT merge_record.source_asset_id,merge_record.canonical_asset_id,chain.depth+1
|
||||||
|
FROM asset_merges merge_record
|
||||||
|
JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id
|
||||||
|
WHERE chain.depth<32
|
||||||
|
)
|
||||||
|
SELECT canonical_asset_id AS id FROM chain ORDER BY depth DESC LIMIT 1
|
||||||
|
`, [assetId])) as Array<{ id: string }>;
|
||||||
|
return rows[0]?.id ?? assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mergeChain(assetId: string): Promise<MergeRow[]> {
|
||||||
|
return (await this.dataSource.query(`
|
||||||
|
WITH RECURSIVE chain AS (
|
||||||
|
SELECT id,source_asset_id,canonical_asset_id,reason,merged_at,merged_by,source,request_id,1 AS depth
|
||||||
|
FROM asset_merges WHERE source_asset_id=$1::uuid
|
||||||
|
UNION ALL
|
||||||
|
SELECT merge_record.id,merge_record.source_asset_id,merge_record.canonical_asset_id,
|
||||||
|
merge_record.reason,merge_record.merged_at,merge_record.merged_by,merge_record.source,merge_record.request_id,
|
||||||
|
chain.depth+1
|
||||||
|
FROM asset_merges merge_record
|
||||||
|
JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id
|
||||||
|
WHERE chain.depth<32
|
||||||
|
)
|
||||||
|
SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
|
||||||
|
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
|
||||||
|
FROM chain ORDER BY depth
|
||||||
|
`, [assetId])) as MergeRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async aliasesFor(canonicalAssetId: string) {
|
||||||
|
return this.dataSource.query(`
|
||||||
|
WITH RECURSIVE aliases AS (
|
||||||
|
SELECT source_asset_id,canonical_asset_id,reason,merged_at,1 AS depth
|
||||||
|
FROM asset_merges WHERE canonical_asset_id=$1::uuid
|
||||||
|
UNION ALL
|
||||||
|
SELECT merge_record.source_asset_id,merge_record.canonical_asset_id,
|
||||||
|
merge_record.reason,merge_record.merged_at,aliases.depth+1
|
||||||
|
FROM asset_merges merge_record
|
||||||
|
JOIN aliases ON merge_record.canonical_asset_id=aliases.source_asset_id
|
||||||
|
WHERE aliases.depth<32
|
||||||
|
)
|
||||||
|
SELECT alias.source_asset_id AS id,asset.code,asset.name,alias.reason,
|
||||||
|
alias.merged_at AS "mergedAt",alias.depth
|
||||||
|
FROM aliases alias
|
||||||
|
JOIN assets asset ON asset.id=alias.source_asset_id
|
||||||
|
ORDER BY alias.merged_at,alias.depth
|
||||||
|
`, [canonicalAssetId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async snapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
SELECT TO_JSONB(asset) || JSONB_BUILD_OBJECT(
|
||||||
|
'typeCode',type.code,
|
||||||
|
'typeName',type.name,
|
||||||
|
'inventoryFamily',CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id',family.id,'code',family.code,'name',family.name,'level',family.level
|
||||||
|
) END
|
||||||
|
) AS snapshot
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
|
WHERE asset.id=$1::uuid
|
||||||
|
`, [assetId])) as Array<{ snapshot: Record<string, unknown> }>;
|
||||||
|
if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
||||||
|
return rows[0].snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Body, Controller, Get, Param, 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 { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
|
||||||
|
import { InventoryStructureService } from './inventory-structure.service';
|
||||||
|
|
||||||
|
@Controller('inventory-structure')
|
||||||
|
export class InventoryStructureController {
|
||||||
|
constructor(private readonly inventoryStructure: InventoryStructureService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
options() {
|
||||||
|
return this.inventoryStructure.options();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('parents/:kind')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
parents(
|
||||||
|
@Param('kind') kind: string,
|
||||||
|
@Query('search') search?: string,
|
||||||
|
) {
|
||||||
|
return this.inventoryStructure.parents(kind, search);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('assets.create')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateInventoryStructureDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.inventoryStructure.create(dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import {
|
||||||
|
AssetDataOrigin,
|
||||||
|
AssetInformationStatus,
|
||||||
|
AssetOperationalStatus,
|
||||||
|
AssetVersionChangeType,
|
||||||
|
AuditAction,
|
||||||
|
} from '../database/entities';
|
||||||
|
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
|
||||||
|
type StructureTypeRow = { id: string; code: string; name: string };
|
||||||
|
type FamilyRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||||
|
legacyTypeCode: string | null;
|
||||||
|
informationLabels: string[];
|
||||||
|
parentFamilyId: string | null;
|
||||||
|
parentFamilyCode: string | null;
|
||||||
|
parentFamilyName: string | null;
|
||||||
|
};
|
||||||
|
type ParentRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
typeCode: string;
|
||||||
|
operationalAreaId: string | null;
|
||||||
|
operatorCompanyId: string | null;
|
||||||
|
inventoryFamilyId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = {
|
||||||
|
AREA: 'area',
|
||||||
|
YACIMIENTO: 'yacimiento',
|
||||||
|
INSTALACION: 'instalacion',
|
||||||
|
SUBINSTALACION: 'subinstalacion',
|
||||||
|
};
|
||||||
|
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
|
||||||
|
AREA: null,
|
||||||
|
YACIMIENTO: 'area',
|
||||||
|
INSTALACION: 'yacimiento',
|
||||||
|
SUBINSTALACION: 'instalacion',
|
||||||
|
};
|
||||||
|
const FAMILY_LEVEL_BY_KIND: Partial<Record<InventoryStructureKind, FamilyRow['level']>> = {
|
||||||
|
INSTALACION: 'INSTALLATION',
|
||||||
|
SUBINSTALACION: 'SUBINSTALLATION',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InventoryStructureService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly history: AssetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async options() {
|
||||||
|
const types = (await this.dataSource.query(`
|
||||||
|
SELECT id,code,name
|
||||||
|
FROM asset_types
|
||||||
|
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
|
||||||
|
AND is_active=true
|
||||||
|
ORDER BY CASE lower(code)
|
||||||
|
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2
|
||||||
|
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END
|
||||||
|
`)) as StructureTypeRow[];
|
||||||
|
if (types.length !== 4) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
|
||||||
|
message: 'La estructura del Inventario todavía no está completamente configurada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const families = (await this.dataSource.query(`
|
||||||
|
SELECT family.id,family.code,family.name,family.level,
|
||||||
|
family.legacy_type_code AS "legacyTypeCode",
|
||||||
|
family.information_labels AS "informationLabels",
|
||||||
|
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
|
||||||
|
FROM inventory_families family
|
||||||
|
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
|
||||||
|
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||||
|
WHERE family.is_active=true
|
||||||
|
ORDER BY family.level,family.name,family.code
|
||||||
|
`)) as FamilyRow[];
|
||||||
|
return {
|
||||||
|
levels: [
|
||||||
|
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false },
|
||||||
|
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
|
||||||
|
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
|
||||||
|
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
|
||||||
|
],
|
||||||
|
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
|
||||||
|
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async parents(kindValue: string, search?: string) {
|
||||||
|
const kind = kindValue.toUpperCase() as InventoryStructureKind;
|
||||||
|
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
|
||||||
|
message: 'El nivel indicado no requiere un registro padre',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const expectedType = PARENT_TYPE_BY_KIND[kind];
|
||||||
|
const parameters: unknown[] = [expectedType];
|
||||||
|
let searchSql = '';
|
||||||
|
if (search?.trim()) {
|
||||||
|
parameters.push(`%${search.trim()}%`);
|
||||||
|
searchSql = `AND (asset.code ILIKE $2 OR asset.name ILIKE $2 OR COALESCE(asset.common_name,'') ILIKE $2)`;
|
||||||
|
}
|
||||||
|
const rows = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
|
||||||
|
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||||
|
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
|
||||||
|
'informationLabels',family.information_labels
|
||||||
|
) END AS "inventoryFamily",
|
||||||
|
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id',parent.id,'code',parent.code,'name',parent.name
|
||||||
|
) END AS parent
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
|
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
||||||
|
WHERE lower(type.code)=lower($1::text)
|
||||||
|
AND asset.information_status<>'INACTIVE'
|
||||||
|
${searchSql}
|
||||||
|
ORDER BY asset.name,asset.code
|
||||||
|
LIMIT 80
|
||||||
|
`, parameters);
|
||||||
|
return { data: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateInventoryStructureDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const type = await this.requireStructureType(manager, dto.kind);
|
||||||
|
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
|
||||||
|
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
|
||||||
|
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
||||||
|
const operationalAreaId = parent?.operationalAreaId ?? null;
|
||||||
|
const operatorCompanyId = parent?.operatorCompanyId ?? null;
|
||||||
|
|
||||||
|
const inserted = (await manager.query(`
|
||||||
|
INSERT INTO assets (
|
||||||
|
asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
|
||||||
|
code,name,common_name,description,information_status,operational_status,
|
||||||
|
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
||||||
|
) VALUES (
|
||||||
|
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,
|
||||||
|
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status,
|
||||||
|
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid
|
||||||
|
) RETURNING id
|
||||||
|
`, [
|
||||||
|
type.id,
|
||||||
|
parent?.id ?? null,
|
||||||
|
operationalAreaId,
|
||||||
|
operatorCompanyId,
|
||||||
|
family?.id ?? null,
|
||||||
|
code,
|
||||||
|
dto.name,
|
||||||
|
dto.commonName ?? null,
|
||||||
|
dto.description ?? null,
|
||||||
|
AssetInformationStatus.DRAFT,
|
||||||
|
AssetOperationalStatus.UNKNOWN,
|
||||||
|
AssetDataOrigin.MANUAL,
|
||||||
|
'Inventario estructural F3.1',
|
||||||
|
`inventory-structure:${dto.kind.toLowerCase()}`,
|
||||||
|
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
|
||||||
|
principal.userId,
|
||||||
|
])) as Array<{ id: string }>;
|
||||||
|
const id = inserted[0]?.id;
|
||||||
|
if (!id) throw new Error('No se pudo crear el registro estructural');
|
||||||
|
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
id,
|
||||||
|
AssetVersionChangeType.CREATED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO asset_context_history (
|
||||||
|
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
||||||
|
change_reason,asset_version_number,source,request_id,created_by
|
||||||
|
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
|
||||||
|
`, [
|
||||||
|
id,
|
||||||
|
parent?.id ?? null,
|
||||||
|
operationalAreaId,
|
||||||
|
operatorCompanyId,
|
||||||
|
'Alta guiada de Inventario estructural F3.1',
|
||||||
|
versionNumber,
|
||||||
|
request.requestId,
|
||||||
|
principal.userId,
|
||||||
|
]);
|
||||||
|
const created = await this.loadView(manager, id);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_CREATED,
|
||||||
|
entityType: 'asset',
|
||||||
|
entityId: id,
|
||||||
|
afterData: created as unknown as Record<string, unknown>,
|
||||||
|
metadata: {
|
||||||
|
versionNumber,
|
||||||
|
inventoryStructureKind: dto.kind,
|
||||||
|
inventoryFamilyId: family?.id ?? null,
|
||||||
|
inventoryFamilyCode: family?.code ?? null,
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'ASSET_CODE_ALREADY_EXISTS',
|
||||||
|
message: 'Ya existe un registro de Inventario con ese código',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
SELECT id,code,name FROM asset_types
|
||||||
|
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1
|
||||||
|
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[];
|
||||||
|
if (!rows[0]) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
|
||||||
|
message: `El nivel ${kind} no está configurado`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireParent(
|
||||||
|
manager: EntityManager,
|
||||||
|
kind: InventoryStructureKind,
|
||||||
|
parentId: string | null,
|
||||||
|
): Promise<ParentRow | null> {
|
||||||
|
const expectedType = PARENT_TYPE_BY_KIND[kind];
|
||||||
|
if (!expectedType) {
|
||||||
|
if (parentId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_AREA_MUST_BE_ROOT',
|
||||||
|
message: 'Un Área se crea como registro raíz y no puede tener padre',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parentId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
|
||||||
|
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
||||||
|
asset.operational_area_id AS "operationalAreaId",
|
||||||
|
asset.operator_company_id AS "operatorCompanyId",
|
||||||
|
asset.inventory_family_id AS "inventoryFamilyId"
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
|
||||||
|
FOR KEY SHARE
|
||||||
|
`, [parentId])) as ParentRow[];
|
||||||
|
const parent = rows[0];
|
||||||
|
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
|
||||||
|
if (parent.typeCode.toLowerCase() !== expectedType) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
|
||||||
|
message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireFamily(
|
||||||
|
manager: EntityManager,
|
||||||
|
kind: InventoryStructureKind,
|
||||||
|
familyId: string | null,
|
||||||
|
parent: ParentRow | null,
|
||||||
|
): Promise<FamilyRow | null> {
|
||||||
|
const expectedLevel = FAMILY_LEVEL_BY_KIND[kind];
|
||||||
|
if (!expectedLevel) {
|
||||||
|
if (familyId) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
||||||
|
message: 'Área y Yacimiento no llevan familia técnica',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!familyId) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
|
||||||
|
message: `Elegí la familia técnica de la ${kind.toLowerCase()}`,
|
||||||
|
});
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
SELECT family.id,family.code,family.name,family.level,
|
||||||
|
family.legacy_type_code AS "legacyTypeCode",
|
||||||
|
family.information_labels AS "informationLabels",
|
||||||
|
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
|
||||||
|
FROM inventory_families family
|
||||||
|
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
|
||||||
|
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||||
|
WHERE family.id=$1::uuid AND family.is_active=true
|
||||||
|
LIMIT 1
|
||||||
|
`, [familyId])) as FamilyRow[];
|
||||||
|
const family = rows[0];
|
||||||
|
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' });
|
||||||
|
if (family.level !== expectedLevel) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
||||||
|
message: 'La familia técnica no corresponde al nivel seleccionado',
|
||||||
|
});
|
||||||
|
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
|
||||||
|
message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return family;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
||||||
|
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
|
||||||
|
const readable = name
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 48) || 'REGISTRO';
|
||||||
|
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadView(manager: EntityManager, id: string) {
|
||||||
|
const rows = await manager.query(`
|
||||||
|
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
|
||||||
|
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
|
||||||
|
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||||
|
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
||||||
|
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
|
||||||
|
'informationLabels',family.information_labels
|
||||||
|
) END AS "inventoryFamily",
|
||||||
|
asset.created_at AS "createdAt",asset.updated_at AS "updatedAt"
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
||||||
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
|
WHERE asset.id=$1::uuid
|
||||||
|
`, [id]);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
import { InventoryMergeService } from './inventory-merge.service';
|
||||||
|
|
||||||
|
type LooseRecord = Record<string, any>;
|
||||||
|
|
||||||
|
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: T[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const id = String(item.id ?? '');
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const first = fieldCandidates.map((field) => b[field]).find(Boolean) ?? 0;
|
||||||
|
const second = fieldCandidates.map((field) => a[field]).find(Boolean) ?? 0;
|
||||||
|
return new Date(String(first)).getTime() - new Date(String(second)).getTime();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MergedInventoryDossierService {
|
||||||
|
constructor(
|
||||||
|
private readonly assets: AssetsService,
|
||||||
|
private readonly merges: InventoryMergeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
|
||||||
|
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
|
||||||
|
const canonical = mergeStatus.canonical as LooseRecord;
|
||||||
|
const requested = mergeStatus.requested as LooseRecord;
|
||||||
|
const aliases = (mergeStatus.aliases ?? []) as LooseRecord[];
|
||||||
|
const inventoryIds = [canonical.id, ...aliases.map((alias) => alias.id)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(String)
|
||||||
|
.filter((value, index, all) => all.indexOf(value) === index);
|
||||||
|
|
||||||
|
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
|
||||||
|
const dossier = await this.assets.dossier(assetId) as LooseRecord;
|
||||||
|
const identity = dossier.asset as LooseRecord;
|
||||||
|
return { assetId, identity, dossier };
|
||||||
|
}));
|
||||||
|
|
||||||
|
const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({
|
||||||
|
...entry,
|
||||||
|
historicalInventory: {
|
||||||
|
id: identity.id,
|
||||||
|
code: identity.code,
|
||||||
|
name: identity.name,
|
||||||
|
commonName: identity.commonName ?? null,
|
||||||
|
isCanonical: identity.id === canonical.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const collect = (key: string): LooseRecord[] => dedupeById(
|
||||||
|
dossiers.flatMap(({ identity, dossier }) =>
|
||||||
|
((dossier[key] ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visits = sortDesc(collect('visits'), ['actualStartedAt', 'plannedStartAt', 'createdAt']);
|
||||||
|
const acts = sortDesc(collect('acts'), ['occurredAt', 'createdAt']);
|
||||||
|
const findings = sortDesc(collect('findings'), ['createdAt', 'updatedAt']);
|
||||||
|
const evidence = sortDesc(collect('evidence'), ['capturedAt', 'createdAt']);
|
||||||
|
const communications = sortDesc(collect('communications'), ['occurredAt', 'createdAt']);
|
||||||
|
const verificationResults = sortDesc(collect('verificationResults'), ['verifiedAt', 'resultRecordedAt']);
|
||||||
|
const documents = sortDesc(collect('documents'), ['documentDate', 'linkedAt']);
|
||||||
|
const inspectionReports = sortDesc(collect('inspectionReports'), ['generatedAt']);
|
||||||
|
const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']);
|
||||||
|
const versions = sortDesc(collect('versions'), ['occurredAt']);
|
||||||
|
|
||||||
|
const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) =>
|
||||||
|
((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({
|
||||||
|
...event,
|
||||||
|
meta: {
|
||||||
|
...(event.meta ?? {}),
|
||||||
|
historicalInventory: {
|
||||||
|
id: identity.id,
|
||||||
|
code: identity.code,
|
||||||
|
name: identity.name,
|
||||||
|
isCanonical: identity.id === canonical.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const timeline: LooseRecord[] = dedupeById<LooseRecord>(timelineSource);
|
||||||
|
|
||||||
|
for (const alias of aliases) {
|
||||||
|
timeline.push({
|
||||||
|
id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`,
|
||||||
|
kind: 'MERGE',
|
||||||
|
occurredAt: alias.mergedAt,
|
||||||
|
title: `Inventario fusionado · ${String(alias.code)}`,
|
||||||
|
description: alias.reason,
|
||||||
|
meta: {
|
||||||
|
sourceAssetId: alias.id,
|
||||||
|
sourceCode: alias.code,
|
||||||
|
sourceName: alias.name,
|
||||||
|
canonicalAssetId: canonical.id,
|
||||||
|
canonicalCode: canonical.code,
|
||||||
|
canonicalName: canonical.name,
|
||||||
|
historyPreserved: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
timeline.sort((a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime());
|
||||||
|
|
||||||
|
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
|
||||||
|
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
|
||||||
|
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
asset: {
|
||||||
|
id: canonical.id,
|
||||||
|
code: canonical.code,
|
||||||
|
name: canonical.name,
|
||||||
|
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
|
||||||
|
},
|
||||||
|
requestedAsset: {
|
||||||
|
id: requested.id,
|
||||||
|
code: requested.code,
|
||||||
|
name: requested.name,
|
||||||
|
},
|
||||||
|
merge: {
|
||||||
|
requestedWasMerged: requested.id !== canonical.id,
|
||||||
|
canonicalAssetId: canonical.id,
|
||||||
|
aliases,
|
||||||
|
chain: mergeStatus.chain ?? [],
|
||||||
|
aggregatedInventoryIds: inventoryIds,
|
||||||
|
historyPolicy: 'CHRONOLOGICAL_AGGREGATION_WITH_ORIGINAL_IDENTITY',
|
||||||
|
},
|
||||||
|
counters: {
|
||||||
|
inspections: visits.length,
|
||||||
|
acts: acts.length,
|
||||||
|
findings: findings.length,
|
||||||
|
verifications: verificationResults.length,
|
||||||
|
openFindings,
|
||||||
|
closedFindings,
|
||||||
|
evidence: evidence.length,
|
||||||
|
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
|
||||||
|
photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length,
|
||||||
|
reports: reports.length + inspectionReports.length,
|
||||||
|
},
|
||||||
|
visits,
|
||||||
|
acts,
|
||||||
|
findings,
|
||||||
|
evidence,
|
||||||
|
communications,
|
||||||
|
verificationResults,
|
||||||
|
documents,
|
||||||
|
inspectionReports,
|
||||||
|
reports,
|
||||||
|
media,
|
||||||
|
versions,
|
||||||
|
timeline: timeline.slice(0, 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { AuthorizationModule } from '../authorization/authorization.module';
|
|||||||
import { PhaseADataModule } from '../core-data/phase-a-data.module';
|
import { PhaseADataModule } from '../core-data/phase-a-data.module';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { MobileAuthController } from './mobile-auth.controller';
|
||||||
|
import { MobileAuthService } from './mobile-auth.service';
|
||||||
import { AccessTokenGuard } from './guards/access-token.guard';
|
import { AccessTokenGuard } from './guards/access-token.guard';
|
||||||
import { CsrfGuard } from './guards/csrf.guard';
|
import { CsrfGuard } from './guards/csrf.guard';
|
||||||
import { AuthConfigService } from '../common/config/auth-config.service';
|
import { AuthConfigService } from '../common/config/auth-config.service';
|
||||||
@@ -18,6 +20,7 @@ const providers = [
|
|||||||
PasswordService,
|
PasswordService,
|
||||||
TokenService,
|
TokenService,
|
||||||
AuthService,
|
AuthService,
|
||||||
|
MobileAuthService,
|
||||||
AccessTokenGuard,
|
AccessTokenGuard,
|
||||||
CsrfGuard,
|
CsrfGuard,
|
||||||
];
|
];
|
||||||
@@ -29,7 +32,7 @@ const providers = [
|
|||||||
AuditModule,
|
AuditModule,
|
||||||
AuthorizationModule,
|
AuthorizationModule,
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController, MobileAuthController],
|
||||||
providers,
|
providers,
|
||||||
exports: [
|
exports: [
|
||||||
AuthConfigService,
|
AuthConfigService,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class MobileRefreshDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(32)
|
||||||
|
@MaxLength(256)
|
||||||
|
refreshToken!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../common/http/request-context';
|
||||||
|
import { CurrentAuth } from './decorators/current-auth.decorator';
|
||||||
|
import { Public } from './decorators/public.decorator';
|
||||||
|
import { SkipCsrf } from './decorators/skip-csrf.decorator';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { MobileRefreshDto } from './dto/mobile-refresh.dto';
|
||||||
|
import { MobileAuthService } from './mobile-auth.service';
|
||||||
|
|
||||||
|
@Controller('auth/mobile')
|
||||||
|
export class MobileAuthController {
|
||||||
|
constructor(private readonly mobileAuth: MobileAuthService) {}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(200)
|
||||||
|
@Public()
|
||||||
|
@SkipCsrf()
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60_000, blockDuration: 60_000 } })
|
||||||
|
login(
|
||||||
|
@Body() dto: LoginDto,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.mobileAuth.login(dto, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(200)
|
||||||
|
@Public()
|
||||||
|
@SkipCsrf()
|
||||||
|
@Throttle({ default: { limit: 20, ttl: 60_000 } })
|
||||||
|
refresh(
|
||||||
|
@Body() dto: MobileRefreshDto,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.mobileAuth.refresh(dto.refreshToken, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(200)
|
||||||
|
logout(
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.mobileAuth.logout(principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import { isIP } from 'node:net';
|
||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type {
|
||||||
|
AuthPrincipal,
|
||||||
|
RequestWithContext,
|
||||||
|
} from '../common/http/request-context';
|
||||||
|
import { AuthSessionsRepository } from '../core-data/repositories/auth-sessions.repository';
|
||||||
|
import { RolesRepository } from '../core-data/repositories/roles.repository';
|
||||||
|
import { UsersRepository } from '../core-data/repositories/users.repository';
|
||||||
|
import {
|
||||||
|
AuditAction,
|
||||||
|
AuditSource,
|
||||||
|
AuthSession,
|
||||||
|
User,
|
||||||
|
UserStatus,
|
||||||
|
} from '../database/entities';
|
||||||
|
import type { LoginDto } from './dto/login.dto';
|
||||||
|
import { PasswordService } from './services/password.service';
|
||||||
|
import {
|
||||||
|
IssuedRefreshToken,
|
||||||
|
TokenService,
|
||||||
|
} from './services/token.service';
|
||||||
|
|
||||||
|
interface RequestMetadata {
|
||||||
|
ip: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SuccessfulAuthentication {
|
||||||
|
user: User;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: IssuedRefreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginOutcome = SuccessfulAuthentication | null;
|
||||||
|
type RefreshOutcome =
|
||||||
|
| ({ kind: 'ok' } & SuccessfulAuthentication)
|
||||||
|
| { kind: 'reuse' }
|
||||||
|
| { kind: 'invalid' };
|
||||||
|
|
||||||
|
function invalidCredentials(): UnauthorizedException {
|
||||||
|
return new UnauthorizedException({
|
||||||
|
code: 'INVALID_CREDENTIALS',
|
||||||
|
message: 'Credenciales inválidas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidSession(): UnauthorizedException {
|
||||||
|
return new UnauthorizedException({
|
||||||
|
code: 'INVALID_SESSION',
|
||||||
|
message: 'Sesión móvil inválida o vencida',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectorRequired(): ForbiddenException {
|
||||||
|
return new ForbiddenException({
|
||||||
|
code: 'INSPECTION_INSPECTOR_ROLE_REQUIRED',
|
||||||
|
message: 'Sólo un usuario con rol inspector puede ingresar a la aplicación móvil',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMetadata(request: RequestWithContext): RequestMetadata {
|
||||||
|
const candidateIp = request.ip || request.socket.remoteAddress || '';
|
||||||
|
const ip = isIP(candidateIp) ? candidateIp : null;
|
||||||
|
const rawUserAgent = request.header('user-agent')?.trim();
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
userAgent: rawUserAgent ? rawUserAgent.slice(0, 2048) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MobileAuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly users: UsersRepository,
|
||||||
|
private readonly sessions: AuthSessionsRepository,
|
||||||
|
private readonly roles: RolesRepository,
|
||||||
|
private readonly passwords: PasswordService,
|
||||||
|
private readonly tokens: TokenService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(dto: LoginDto, request: RequestWithContext) {
|
||||||
|
const identifier = dto.identifier.trim().toLowerCase();
|
||||||
|
const candidate = await this.users.findForAuthentication(identifier);
|
||||||
|
const metadata = requestMetadata(request);
|
||||||
|
|
||||||
|
if (!candidate) {
|
||||||
|
await this.passwords.verifyUnknown(dto.password);
|
||||||
|
await this.audit.record({
|
||||||
|
action: AuditAction.AUTH_LOGIN_FAILED,
|
||||||
|
requestId: request.requestId,
|
||||||
|
source: AuditSource.ANDROID,
|
||||||
|
...metadata,
|
||||||
|
metadata: { identifier, reason: 'UNKNOWN_IDENTIFIER' },
|
||||||
|
});
|
||||||
|
throw invalidCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await this.dataSource.transaction<LoginOutcome>(
|
||||||
|
async (manager) => {
|
||||||
|
const user = await manager
|
||||||
|
.getRepository(User)
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.passwordHash')
|
||||||
|
.where('user.id = :id', { id: candidate.id })
|
||||||
|
.setLock('pessimistic_write')
|
||||||
|
.getOne();
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const passwordMatches = await this.passwords.verify(
|
||||||
|
user.passwordHash,
|
||||||
|
dto.password,
|
||||||
|
);
|
||||||
|
const locked = Boolean(user.lockedUntil && user.lockedUntil > now);
|
||||||
|
const active = user.status === UserStatus.ACTIVE;
|
||||||
|
|
||||||
|
if (!passwordMatches || locked || !active) {
|
||||||
|
if (!passwordMatches && active && !locked) {
|
||||||
|
await manager.query(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
failed_login_attempts = failed_login_attempts + 1,
|
||||||
|
locked_until = CASE
|
||||||
|
WHEN failed_login_attempts + 1 >= $2
|
||||||
|
THEN CURRENT_TIMESTAMP + ($3 * INTERVAL '1 second')
|
||||||
|
ELSE locked_until
|
||||||
|
END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
user.id,
|
||||||
|
this.tokens.maxLoginAttempts,
|
||||||
|
this.tokens.lockoutSeconds,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
actorUserId: user.id,
|
||||||
|
actorUsername: user.username,
|
||||||
|
action: AuditAction.AUTH_LOGIN_FAILED,
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: user.id,
|
||||||
|
requestId: request.requestId,
|
||||||
|
source: AuditSource.ANDROID,
|
||||||
|
...metadata,
|
||||||
|
metadata: {
|
||||||
|
reason: !active
|
||||||
|
? 'INACTIVE_USER'
|
||||||
|
: locked
|
||||||
|
? 'LOCKED_USER'
|
||||||
|
: 'INVALID_PASSWORD',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.passwords.needsRehash(user.passwordHash)) {
|
||||||
|
user.passwordHash = await this.passwords.hash(dto.password);
|
||||||
|
}
|
||||||
|
user.failedLoginAttempts = 0;
|
||||||
|
user.lockedUntil = null;
|
||||||
|
user.lastLoginAt = now;
|
||||||
|
await manager.getRepository(User).save(user);
|
||||||
|
|
||||||
|
const refreshToken = this.tokens.issueRefreshToken();
|
||||||
|
const session = manager.getRepository(AuthSession).create({
|
||||||
|
id: refreshToken.sessionId,
|
||||||
|
userId: user.id,
|
||||||
|
refreshTokenHash: refreshToken.tokenHash,
|
||||||
|
expiresAt: this.tokens.refreshExpiresAt(now),
|
||||||
|
lastUsedAt: now,
|
||||||
|
revokedAt: null,
|
||||||
|
replacedBySessionId: null,
|
||||||
|
ip: metadata.ip,
|
||||||
|
userAgent: metadata.userAgent,
|
||||||
|
deviceLabel: dto.deviceLabel?.trim() || 'DH Android',
|
||||||
|
});
|
||||||
|
await manager.getRepository(AuthSession).save(session);
|
||||||
|
|
||||||
|
const accessToken = await this.tokens.issueAccessToken({
|
||||||
|
userId: user.id,
|
||||||
|
sessionId: session.id,
|
||||||
|
username: user.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
actorUserId: user.id,
|
||||||
|
actorUsername: user.username,
|
||||||
|
action: AuditAction.AUTH_LOGIN_SUCCESS,
|
||||||
|
entityType: 'auth_session',
|
||||||
|
entityId: session.id,
|
||||||
|
requestId: request.requestId,
|
||||||
|
source: AuditSource.ANDROID,
|
||||||
|
...metadata,
|
||||||
|
metadata: { deviceLabel: session.deviceLabel },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { user, accessToken, refreshToken };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!outcome) throw invalidCredentials();
|
||||||
|
return this.complete(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(rawToken: string, request: RequestWithContext) {
|
||||||
|
const parsed = this.tokens.parseRefreshToken(rawToken);
|
||||||
|
if (!parsed) throw invalidSession();
|
||||||
|
|
||||||
|
const metadata = requestMetadata(request);
|
||||||
|
const outcome = await this.dataSource.transaction<RefreshOutcome>(
|
||||||
|
async (manager) => {
|
||||||
|
const session = await manager
|
||||||
|
.getRepository(AuthSession)
|
||||||
|
.createQueryBuilder('session')
|
||||||
|
.addSelect('session.refreshTokenHash')
|
||||||
|
.where('session.id = :id', { id: parsed.sessionId })
|
||||||
|
.setLock('pessimistic_write')
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!session ||
|
||||||
|
!this.tokens.verifyRefreshToken(rawToken, session.refreshTokenHash)
|
||||||
|
) {
|
||||||
|
return { kind: 'invalid' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await manager.getRepository(User).findOne({
|
||||||
|
where: { id: session.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.revokedAt) {
|
||||||
|
await this.sessions.revokeSessionFamily(session.id, manager);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
actorUserId: user?.id ?? null,
|
||||||
|
actorUsername: user?.username ?? null,
|
||||||
|
action: AuditAction.AUTH_REFRESH_REUSE_DETECTED,
|
||||||
|
entityType: 'auth_session',
|
||||||
|
entityId: session.id,
|
||||||
|
requestId: request.requestId,
|
||||||
|
source: AuditSource.ANDROID,
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
return { kind: 'reuse' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (!user || user.status !== UserStatus.ACTIVE || session.expiresAt <= now) {
|
||||||
|
session.revokedAt = now;
|
||||||
|
session.lastUsedAt = now;
|
||||||
|
await manager.getRepository(AuthSession).save(session);
|
||||||
|
if (user?.status === UserStatus.INACTIVE) {
|
||||||
|
await this.sessions.revokeUserSessions(user.id, undefined, manager);
|
||||||
|
}
|
||||||
|
return { kind: 'invalid' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = this.tokens.issueRefreshToken();
|
||||||
|
const replacement = manager.getRepository(AuthSession).create({
|
||||||
|
id: refreshToken.sessionId,
|
||||||
|
userId: user.id,
|
||||||
|
refreshTokenHash: refreshToken.tokenHash,
|
||||||
|
expiresAt: this.tokens.refreshExpiresAt(now),
|
||||||
|
lastUsedAt: now,
|
||||||
|
revokedAt: null,
|
||||||
|
replacedBySessionId: null,
|
||||||
|
ip: metadata.ip,
|
||||||
|
userAgent: metadata.userAgent,
|
||||||
|
deviceLabel: session.deviceLabel,
|
||||||
|
});
|
||||||
|
await manager.getRepository(AuthSession).save(replacement);
|
||||||
|
|
||||||
|
session.revokedAt = now;
|
||||||
|
session.lastUsedAt = now;
|
||||||
|
session.replacedBySessionId = replacement.id;
|
||||||
|
await manager.getRepository(AuthSession).save(session);
|
||||||
|
|
||||||
|
const accessToken = await this.tokens.issueAccessToken({
|
||||||
|
userId: user.id,
|
||||||
|
sessionId: replacement.id,
|
||||||
|
username: user.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
actorUserId: user.id,
|
||||||
|
actorUsername: user.username,
|
||||||
|
action: AuditAction.AUTH_REFRESH,
|
||||||
|
entityType: 'auth_session',
|
||||||
|
entityId: replacement.id,
|
||||||
|
requestId: request.requestId,
|
||||||
|
source: AuditSource.ANDROID,
|
||||||
|
...metadata,
|
||||||
|
metadata: { replacedSessionId: session.id },
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { kind: 'ok', user, accessToken, refreshToken };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (outcome.kind !== 'ok') throw invalidSession();
|
||||||
|
return this.complete(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const metadata = requestMetadata(request);
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.sessions.revokeSession(
|
||||||
|
principal.sessionId,
|
||||||
|
principal.userId,
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
await this.audit.record(
|
||||||
|
{
|
||||||
|
actorUserId: principal.userId,
|
||||||
|
actorUsername: principal.username,
|
||||||
|
action: AuditAction.AUTH_LOGOUT,
|
||||||
|
entityType: 'auth_session',
|
||||||
|
entityId: principal.sessionId,
|
||||||
|
requestId: request.requestId,
|
||||||
|
source: AuditSource.ANDROID,
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async complete(authentication: SuccessfulAuthentication) {
|
||||||
|
const [roleCodes, permissionCodes] = await Promise.all([
|
||||||
|
this.roles.findRoleCodesForUser(authentication.user.id),
|
||||||
|
this.roles.findPermissionCodesForUser(authentication.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!roleCodes.includes('inspector')) {
|
||||||
|
await this.sessions.revokeSession(
|
||||||
|
authentication.refreshToken.sessionId,
|
||||||
|
authentication.user.id,
|
||||||
|
);
|
||||||
|
throw inspectorRequired();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: authentication.user.id,
|
||||||
|
username: authentication.user.username,
|
||||||
|
firstName: authentication.user.firstName,
|
||||||
|
lastName: authentication.user.lastName,
|
||||||
|
email: authentication.user.email,
|
||||||
|
mustChangePassword: authentication.user.mustChangePassword,
|
||||||
|
roles: roleCodes,
|
||||||
|
permissions: permissionCodes,
|
||||||
|
},
|
||||||
|
accessToken: authentication.accessToken,
|
||||||
|
refreshToken: authentication.refreshToken.token,
|
||||||
|
accessExpiresInSeconds: this.tokens.accessTokenTtlSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export enum AssetDataOrigin {
|
|||||||
@Index('idx_assets_parent_id', ['parentId'])
|
@Index('idx_assets_parent_id', ['parentId'])
|
||||||
@Index('idx_assets_operational_area_id', ['operationalAreaId'])
|
@Index('idx_assets_operational_area_id', ['operationalAreaId'])
|
||||||
@Index('idx_assets_operator_company_id', ['operatorCompanyId'])
|
@Index('idx_assets_operator_company_id', ['operatorCompanyId'])
|
||||||
|
@Index('idx_assets_inventory_family_id', ['inventoryFamilyId'])
|
||||||
@Index('idx_assets_information_status', ['informationStatus'])
|
@Index('idx_assets_information_status', ['informationStatus'])
|
||||||
@Index('idx_assets_operational_status', ['operationalStatus'])
|
@Index('idx_assets_operational_status', ['operationalStatus'])
|
||||||
@Index('idx_assets_data_origin', ['dataOrigin'])
|
@Index('idx_assets_data_origin', ['dataOrigin'])
|
||||||
@@ -53,6 +54,9 @@ export class Asset extends TimestampedEntity {
|
|||||||
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
|
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
|
||||||
operatorCompanyId!: string | null;
|
operatorCompanyId!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
|
||||||
|
inventoryFamilyId!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 120 })
|
@Column({ type: 'varchar', length: 120 })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export {
|
|||||||
InspectionActSignatureSource,
|
InspectionActSignatureSource,
|
||||||
InspectionActSignatureStatus,
|
InspectionActSignatureStatus,
|
||||||
InspectionActSignerType,
|
InspectionActSignerType,
|
||||||
|
InspectionCompanySignatureManifestation,
|
||||||
} from './inspection-act-signature.entity';
|
} from './inspection-act-signature.entity';
|
||||||
export { FindingCategory } from './finding-category.entity';
|
export { FindingCategory } from './finding-category.entity';
|
||||||
export { FindingCatalogItem } from './finding-catalog-item.entity';
|
export { FindingCatalogItem } from './finding-catalog-item.entity';
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export enum InspectionActSignatureSource {
|
|||||||
ANDROID = 'ANDROID',
|
ANDROID = 'ANDROID',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum InspectionCompanySignatureManifestation {
|
||||||
|
CONFORMITY = 'CONFORMITY',
|
||||||
|
DISSENT = 'DISSENT',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ name: 'inspection_act_signatures' })
|
@Entity({ name: 'inspection_act_signatures' })
|
||||||
@Index('idx_inspection_act_signatures_act_created', ['actId', 'createdAt'])
|
@Index('idx_inspection_act_signatures_act_created', ['actId', 'createdAt'])
|
||||||
@Index('idx_inspection_act_signatures_sha256', ['signaturePayloadSha256'])
|
@Index('idx_inspection_act_signatures_sha256', ['signaturePayloadSha256'])
|
||||||
@@ -51,6 +56,12 @@ export class InspectionActSignature {
|
|||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
reason!: string | null;
|
reason!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'company_manifestation', type: 'varchar', length: 20, nullable: true })
|
||||||
|
companyManifestation!: InspectionCompanySignatureManifestation | null;
|
||||||
|
|
||||||
|
@Column({ name: 'company_statement', type: 'text', nullable: true })
|
||||||
|
companyStatement!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true })
|
@Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true })
|
||||||
originalName!: string | null;
|
originalName!: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export enum InspectionActStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ name: 'inspection_acts' })
|
@Entity({ name: 'inspection_acts' })
|
||||||
@Index('uq_inspection_acts_visit', ['visitId'], { unique: true })
|
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
|
||||||
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
|
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
|
||||||
@Index('uq_inspection_acts_code', ['code'], { unique: true })
|
@Index('uq_inspection_acts_code', ['code'], { unique: true })
|
||||||
@Index('idx_inspection_acts_visit_status', ['visitId', 'status'])
|
@Index('idx_inspection_acts_visit_status', ['visitId', 'status'])
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export enum InspectionReportReviewStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ name: 'inspection_reports' })
|
@Entity({ name: 'inspection_reports' })
|
||||||
@Index('uq_inspection_reports_visit', ['visitId'], { unique: true })
|
@Index('idx_inspection_reports_visit_id', ['visitId'])
|
||||||
@Index('uq_inspection_reports_act', ['actId'], { unique: true })
|
@Index('uq_inspection_reports_act', ['actId'], { unique: true })
|
||||||
@Index('uq_inspection_reports_year_number', ['reportYear', 'reportNumber'], { unique: true })
|
@Index('uq_inspection_reports_year_number', ['reportYear', 'reportNumber'], { unique: true })
|
||||||
@Index('uq_inspection_reports_code', ['code'], { unique: true })
|
@Index('uq_inspection_reports_code', ['code'], { unique: true })
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ export class User extends TimestampedEntity {
|
|||||||
@Column({ type: 'varchar', length: 320, nullable: true })
|
@Column({ type: 'varchar', length: 320, nullable: true })
|
||||||
email!: string | null;
|
email!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: true })
|
||||||
|
dni!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 40, nullable: true })
|
||||||
|
phone!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'job_title', type: 'varchar', length: 160, nullable: true })
|
||||||
|
jobTitle!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'employee_number', type: 'varchar', length: 80, nullable: true })
|
||||||
|
employeeNumber!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'password_hash', type: 'text', select: false })
|
@Column({ name: 'password_hash', type: 'text', select: false })
|
||||||
passwordHash!: string;
|
passwordHash!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time production reset requested before the first clean Android rollout.
|
||||||
|
*
|
||||||
|
* Keeps only structural product configuration plus the single `admin` account.
|
||||||
|
* Operational/business data is removed. Recovery is intentionally performed
|
||||||
|
* from the deploy PRE backup, not through a synthetic down migration.
|
||||||
|
*/
|
||||||
|
export class ResetProductionOperationalData1788652800000 implements MigrationInterface {
|
||||||
|
name = 'ResetProductionOperationalData1788652800000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const adminRows: Array<{ id: string; username: string }> = await queryRunner.query(`
|
||||||
|
SELECT id, username
|
||||||
|
FROM users
|
||||||
|
WHERE lower(trim(username)) = 'admin'
|
||||||
|
ORDER BY id
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (adminRows.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Production reset aborted: expected exactly one username admin, found ${adminRows.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminId = adminRows[0].id;
|
||||||
|
|
||||||
|
const structuralTables = [
|
||||||
|
'roles',
|
||||||
|
'permissions',
|
||||||
|
'role_permissions',
|
||||||
|
'asset_types',
|
||||||
|
'asset_attribute_definitions',
|
||||||
|
'asset_type_parent_rules',
|
||||||
|
'finding_categories',
|
||||||
|
'finding_catalog_items',
|
||||||
|
'finding_catalog_item_asset_types',
|
||||||
|
'finding_catalog_asset_type_profiles',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Snapshot structural row counts so TRUNCATE ... CASCADE can never silently
|
||||||
|
// remove product configuration while still leaving operational tables empty.
|
||||||
|
const structuralCounts = new Map<string, string>();
|
||||||
|
for (const table of structuralTables) {
|
||||||
|
const safeTable = `"${table.replace(/"/g, '""')}"`;
|
||||||
|
const rows: Array<{ total: string }> = await queryRunner.query(
|
||||||
|
`SELECT count(*)::text AS total FROM ${safeTable}`,
|
||||||
|
);
|
||||||
|
structuralCounts.set(table, rows[0]?.total ?? '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminRolesBefore: Array<{ total: string }> = await queryRunner.query(
|
||||||
|
`SELECT count(*)::text AS total FROM user_roles WHERE user_id = $1`,
|
||||||
|
[adminId],
|
||||||
|
);
|
||||||
|
const adminRoleCount = adminRolesBefore[0]?.total ?? '0';
|
||||||
|
if (adminRoleCount === '0') {
|
||||||
|
throw new Error('Production reset aborted: admin has no assigned role');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product configuration that must survive a clean operational start.
|
||||||
|
const preservedTables = new Set([
|
||||||
|
'typeorm_migrations',
|
||||||
|
'users',
|
||||||
|
'user_roles',
|
||||||
|
...structuralTables,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tableRows: Array<{ table_name: string }> = await queryRunner.query(`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_type = 'BASE TABLE'
|
||||||
|
ORDER BY table_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
const operationalTables = tableRows
|
||||||
|
.map((row) => row.table_name)
|
||||||
|
.filter((table) => !preservedTables.has(table));
|
||||||
|
|
||||||
|
if (operationalTables.length > 0) {
|
||||||
|
const quoted = operationalTables
|
||||||
|
.map((table) => `"${table.replace(/"/g, '""')}"`)
|
||||||
|
.join(', ');
|
||||||
|
await queryRunner.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove every user except the explicitly validated administrator.
|
||||||
|
// user_roles for removed users follow their FK cascade.
|
||||||
|
await queryRunner.query(`DELETE FROM users WHERE id <> $1`, [adminId]);
|
||||||
|
|
||||||
|
// A reset must invalidate every prior login token, including admin's.
|
||||||
|
// auth_sessions is operational and was truncated above; admin simply logs in again.
|
||||||
|
await queryRunner.query(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET failed_login_attempts = 0,
|
||||||
|
locked_until = NULL,
|
||||||
|
last_login_at = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
[adminId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalUsers: Array<{ total: string; admins: string }> = await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
count(*)::text AS total,
|
||||||
|
count(*) FILTER (WHERE lower(trim(username)) = 'admin')::text AS admins
|
||||||
|
FROM users
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (finalUsers[0]?.total !== '1' || finalUsers[0]?.admins !== '1') {
|
||||||
|
throw new Error('Production reset verification failed: users table is not admin-only');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalAdminRoles: Array<{ total: string; foreign_users: string }> = await queryRunner.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE user_id = $1)::text AS total,
|
||||||
|
count(*) FILTER (WHERE user_id <> $1)::text AS foreign_users
|
||||||
|
FROM user_roles
|
||||||
|
`,
|
||||||
|
[adminId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
finalAdminRoles[0]?.total !== adminRoleCount ||
|
||||||
|
finalAdminRoles[0]?.foreign_users !== '0'
|
||||||
|
) {
|
||||||
|
throw new Error('Production reset verification failed: admin role assignments changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert every structural table kept exactly the same number of rows.
|
||||||
|
for (const table of structuralTables) {
|
||||||
|
const safeTable = `"${table.replace(/"/g, '""')}"`;
|
||||||
|
const rows: Array<{ total: string }> = await queryRunner.query(
|
||||||
|
`SELECT count(*)::text AS total FROM ${safeTable}`,
|
||||||
|
);
|
||||||
|
const before = structuralCounts.get(table) ?? '0';
|
||||||
|
if (rows[0]?.total !== before) {
|
||||||
|
throw new Error(
|
||||||
|
`Production reset verification failed: structural table ${table} changed (${before} -> ${rows[0]?.total ?? 'unknown'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert that every operational table is empty. This makes the migration
|
||||||
|
// fail atomically if a table was repopulated during the reset transaction.
|
||||||
|
for (const table of operationalTables) {
|
||||||
|
const safeTable = `"${table.replace(/"/g, '""')}"`;
|
||||||
|
const rows: Array<{ total: string }> = await queryRunner.query(
|
||||||
|
`SELECT count(*)::text AS total FROM ${safeTable}`,
|
||||||
|
);
|
||||||
|
if (rows[0]?.total !== '0') {
|
||||||
|
throw new Error(`Production reset verification failed: ${table} is not empty`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a concise server-side record in the migration log for deploy diagnostics.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[production-reset] kept admin=${adminRows[0].username} (${adminId}); preserved ${structuralTables.length} structural tables; cleared ${operationalTables.length} operational tables`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
throw new Error(
|
||||||
|
'ResetProductionOperationalData is irreversible by migration; restore the deploy PRE database backup instead.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF11MultiActInspections1789495200000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF11MultiActInspections1789495200000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit
|
||||||
|
ON inspection_acts (visit_id)
|
||||||
|
WHERE status = 'DRAFT'
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
DROP CONSTRAINT IF EXISTS uq_inspection_reports_visit
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_inspection_reports_visit_id
|
||||||
|
ON inspection_reports (visit_id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_act_signatures
|
||||||
|
ADD COLUMN company_manifestation varchar(20),
|
||||||
|
ADD COLUMN company_statement text
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inspection_act_signatures
|
||||||
|
SET company_manifestation = 'CONFORMITY'
|
||||||
|
WHERE signer_type = 'COMPANY_RESPONSIBLE'
|
||||||
|
AND status = 'SIGNED'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_act_signatures
|
||||||
|
ADD CONSTRAINT chk_inspection_act_signatures_company_manifestation CHECK (
|
||||||
|
(
|
||||||
|
signer_type = 'COMPANY_RESPONSIBLE'
|
||||||
|
AND status = 'SIGNED'
|
||||||
|
AND company_manifestation IN ('CONFORMITY', 'DISSENT')
|
||||||
|
AND (
|
||||||
|
(company_manifestation = 'CONFORMITY' AND company_statement IS NULL)
|
||||||
|
OR
|
||||||
|
(company_manifestation = 'DISSENT' AND LENGTH(TRIM(COALESCE(company_statement, ''))) >= 10)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
OR
|
||||||
|
(
|
||||||
|
NOT (signer_type = 'COMPANY_RESPONSIBLE' AND status = 'SIGNED')
|
||||||
|
AND company_manifestation IS NULL
|
||||||
|
AND company_statement IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT visit_id FROM inspection_acts
|
||||||
|
GROUP BY visit_id HAVING COUNT(*) > 1
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'F1.1 no puede revertirse: existen inspecciones con múltiples actas';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT visit_id FROM inspection_reports
|
||||||
|
GROUP BY visit_id HAVING COUNT(*) > 1
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'F1.1 no puede revertirse: existen inspecciones con múltiples informes';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM inspection_act_signatures
|
||||||
|
WHERE company_manifestation = 'DISSENT'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'F1.1 no puede revertirse: existen firmas en disidencia';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_act_signatures
|
||||||
|
DROP CONSTRAINT chk_inspection_act_signatures_company_manifestation
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_act_signatures
|
||||||
|
DROP COLUMN company_statement,
|
||||||
|
DROP COLUMN company_manifestation
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP INDEX idx_inspection_reports_visit_id`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
ADD CONSTRAINT uq_inspection_reports_visit UNIQUE (visit_id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP INDEX uq_inspection_acts_one_draft_per_visit`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
ADD CONSTRAINT uq_inspection_acts_visit UNIQUE (visit_id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF12ActAdministration1789581600000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF12ActAdministration1789581600000';
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`CREATE TABLE inspection_act_deadline_events (
|
||||||
|
id uuid PRIMARY KEY, act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE RESTRICT,
|
||||||
|
response_due_on date NOT NULL, reason text NOT NULL, created_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_act_created ON inspection_act_deadline_events (act_id, created_at DESC)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_deadline_events_due ON inspection_act_deadline_events (response_due_on)`);
|
||||||
|
await queryRunner.query(`CREATE TABLE inspection_act_company_responses (
|
||||||
|
id uuid PRIMARY KEY, act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE RESTRICT,
|
||||||
|
received_on date NOT NULL, details text NULL, committed_correction_on date NULL,
|
||||||
|
contact_name varchar(200) NULL, contact_email varchar(320) NULL,
|
||||||
|
original_name varchar(255) NULL, stored_name varchar(100) NULL, mime_type varchar(120) NULL,
|
||||||
|
size_bytes bigint NULL, sha256 char(64) NULL, created_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_act_company_response_file CHECK ((stored_name IS NULL AND original_name IS NULL AND mime_type IS NULL AND size_bytes IS NULL AND sha256 IS NULL) OR (stored_name IS NOT NULL AND original_name IS NOT NULL AND mime_type = 'application/pdf' AND size_bytes > 0 AND sha256 IS NOT NULL))
|
||||||
|
)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_company_responses_act_received ON inspection_act_company_responses (act_id, received_on DESC, created_at DESC)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_act_company_responses_commitment ON inspection_act_company_responses (committed_correction_on)`);
|
||||||
|
await queryRunner.query(`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by, created_at)
|
||||||
|
SELECT gen_random_uuid(), act_id, MIN(correction_due_on), 'Migrado desde plazo histórico coincidente de hallazgos', NULL, CURRENT_TIMESTAMP
|
||||||
|
FROM inspection_findings WHERE correction_due_on IS NOT NULL GROUP BY act_id HAVING COUNT(DISTINCT correction_due_on) = 1`);
|
||||||
|
await queryRunner.query(`CREATE OR REPLACE FUNCTION prevent_f12_append_only_mutation() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'F1.2 append-only: update/delete no permitido'; END; $$ LANGUAGE plpgsql`);
|
||||||
|
await queryRunner.query(`CREATE TRIGGER trg_act_deadline_events_append_only BEFORE UPDATE OR DELETE ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION prevent_f12_append_only_mutation()`);
|
||||||
|
await queryRunner.query(`CREATE TRIGGER trg_act_company_responses_append_only BEFORE UPDATE OR DELETE ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION prevent_f12_append_only_mutation()`);
|
||||||
|
}
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_company_responses_append_only ON inspection_act_company_responses`);
|
||||||
|
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_events_append_only ON inspection_act_deadline_events`);
|
||||||
|
await queryRunner.query(`DROP FUNCTION IF EXISTS prevent_f12_append_only_mutation()`);
|
||||||
|
await queryRunner.query(`DROP TABLE inspection_act_company_responses`);
|
||||||
|
await queryRunner.query(`DROP TABLE inspection_act_deadline_events`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
function quoteIdentifier(identifier: string): string {
|
||||||
|
return `"${identifier.replaceAll('"', '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PhaseF21FieldInventory1789668000000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF21FieldInventory1789668000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE field_inventory_code_sequences (
|
||||||
|
year integer PRIMARY KEY,
|
||||||
|
last_value integer NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT chk_field_inventory_code_sequences_year CHECK (year BETWEEN 2000 AND 9999),
|
||||||
|
CONSTRAINT chk_field_inventory_code_sequences_value CHECK (last_value >= 0)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE asset_field_capture_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
visit_id uuid NOT NULL,
|
||||||
|
asset_id uuid NOT NULL,
|
||||||
|
media_id uuid,
|
||||||
|
event_type varchar(24) NOT NULL,
|
||||||
|
device_latitude numeric(9, 6) NOT NULL,
|
||||||
|
device_longitude numeric(9, 6) NOT NULL,
|
||||||
|
device_accuracy_m numeric(12, 3),
|
||||||
|
device_captured_at timestamptz NOT NULL,
|
||||||
|
device_label varchar(255),
|
||||||
|
exif_latitude numeric(9, 6),
|
||||||
|
exif_longitude numeric(9, 6),
|
||||||
|
exif_captured_at timestamptz,
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_asset_field_capture_visit FOREIGN KEY (visit_id)
|
||||||
|
REFERENCES inspection_visits(id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_asset_field_capture_asset FOREIGN KEY (asset_id)
|
||||||
|
REFERENCES assets(id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_asset_field_capture_media FOREIGN KEY (media_id)
|
||||||
|
REFERENCES asset_media(id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_asset_field_capture_created_by FOREIGN KEY (created_by)
|
||||||
|
REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT chk_asset_field_capture_event_type CHECK (event_type IN ('CREATED','PHOTO')),
|
||||||
|
CONSTRAINT chk_asset_field_capture_device_coordinates CHECK (
|
||||||
|
device_latitude BETWEEN -90 AND 90
|
||||||
|
AND device_longitude BETWEEN -180 AND 180
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_asset_field_capture_accuracy CHECK (
|
||||||
|
device_accuracy_m IS NULL OR device_accuracy_m BETWEEN 0 AND 100000
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_asset_field_capture_exif_coordinates CHECK (
|
||||||
|
(exif_latitude IS NULL AND exif_longitude IS NULL)
|
||||||
|
OR (
|
||||||
|
exif_latitude IS NOT NULL
|
||||||
|
AND exif_longitude IS NOT NULL
|
||||||
|
AND exif_latitude BETWEEN -90 AND 90
|
||||||
|
AND exif_longitude BETWEEN -180 AND 180
|
||||||
|
)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_asset_field_capture_media_event CHECK (
|
||||||
|
(event_type = 'CREATED' AND media_id IS NULL)
|
||||||
|
OR (event_type = 'PHOTO' AND media_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_asset_field_capture_visit ON asset_field_capture_events (visit_id, created_at, id)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_asset_field_capture_asset ON asset_field_capture_events (asset_id, created_at, id)`);
|
||||||
|
await queryRunner.query(`CREATE UNIQUE INDEX uq_asset_field_capture_media ON asset_field_capture_events (media_id) WHERE media_id IS NOT NULL`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visit_assets
|
||||||
|
DROP CONSTRAINT chk_inspection_visit_assets_planning_source
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visit_assets
|
||||||
|
ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK (
|
||||||
|
planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION','FIELD')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visit_asset_events
|
||||||
|
DROP CONSTRAINT chk_inspection_visit_asset_events_type
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visit_asset_events
|
||||||
|
ADD CONSTRAINT chk_inspection_visit_asset_events_type CHECK (
|
||||||
|
event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','FIELD_INCLUDED','EXCLUDED','REINCLUDED')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION enforce_field_inventory_capture_before_finding()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
finding_visit_id uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT act.visit_id INTO finding_visit_id
|
||||||
|
FROM inspection_acts act
|
||||||
|
WHERE act.id = NEW.act_id;
|
||||||
|
|
||||||
|
IF finding_visit_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM asset_field_discoveries discovery
|
||||||
|
WHERE discovery.visit_id = finding_visit_id
|
||||||
|
AND discovery.asset_id = NEW.asset_id
|
||||||
|
) THEN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM asset_field_capture_events capture
|
||||||
|
WHERE capture.visit_id = finding_visit_id
|
||||||
|
AND capture.asset_id = NEW.asset_id
|
||||||
|
AND capture.event_type = 'CREATED'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION USING
|
||||||
|
ERRCODE = '23514',
|
||||||
|
MESSAGE = 'FIELD_INVENTORY_GPS_REQUIRED';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM asset_field_capture_events capture
|
||||||
|
WHERE capture.visit_id = finding_visit_id
|
||||||
|
AND capture.asset_id = NEW.asset_id
|
||||||
|
AND capture.event_type = 'PHOTO'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION USING
|
||||||
|
ERRCODE = '23514',
|
||||||
|
MESSAGE = 'FIELD_INVENTORY_PHOTO_REQUIRED';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TRIGGER trg_inspection_findings_field_capture
|
||||||
|
BEFORE INSERT OR UPDATE OF act_id, asset_id ON inspection_findings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION enforce_field_inventory_capture_before_finding()
|
||||||
|
`);
|
||||||
|
|
||||||
|
const appRole = process.env.DB_APP_USER;
|
||||||
|
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
|
||||||
|
const applicationRole = quoteIdentifier(appRole);
|
||||||
|
await queryRunner.query(`GRANT SELECT, INSERT, UPDATE ON TABLE field_inventory_code_sequences TO ${applicationRole}`);
|
||||||
|
await queryRunner.query(`GRANT SELECT, INSERT ON TABLE asset_field_capture_events TO ${applicationRole}`);
|
||||||
|
await queryRunner.query(`REVOKE UPDATE, DELETE ON TABLE asset_field_capture_events FROM ${applicationRole}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM asset_field_capture_events LIMIT 1) THEN
|
||||||
|
RAISE EXCEPTION 'F2.1 no puede revertirse: existen capturas GPS/EXIF de campo';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM inspection_visit_assets WHERE planning_source = 'FIELD' LIMIT 1) THEN
|
||||||
|
RAISE EXCEPTION 'F2.1 no puede revertirse: existen registros seleccionados o creados en campo';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM inspection_visit_asset_events WHERE event_type = 'FIELD_INCLUDED' LIMIT 1) THEN
|
||||||
|
RAISE EXCEPTION 'F2.1 no puede revertirse: existen eventos de Inventario de campo';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP TRIGGER IF EXISTS trg_inspection_findings_field_capture ON inspection_findings`);
|
||||||
|
await queryRunner.query(`DROP FUNCTION IF EXISTS enforce_field_inventory_capture_before_finding()`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_visit_asset_events DROP CONSTRAINT chk_inspection_visit_asset_events_type`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visit_asset_events
|
||||||
|
ADD CONSTRAINT chk_inspection_visit_asset_events_type CHECK (
|
||||||
|
event_type IN ('LEGACY_INCLUDED','AUTO_INCLUDED','PREVENTIVE_INCLUDED','VERIFICATION_INCLUDED','EXCLUDED','REINCLUDED')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_visit_assets DROP CONSTRAINT chk_inspection_visit_assets_planning_source`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visit_assets
|
||||||
|
ADD CONSTRAINT chk_inspection_visit_assets_planning_source CHECK (
|
||||||
|
planning_source IN ('LEGACY','AUTOMATIC','PREVENTIVE','VERIFICATION')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP TABLE asset_field_capture_events`);
|
||||||
|
await queryRunner.query(`DROP TABLE field_inventory_code_sequences`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF221BaseOperationalTypes1789754300000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF221BaseOperationalTypes1789754300000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE asset_types
|
||||||
|
SET operational_role='AREA', can_be_root=true, is_active=true, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE lower(code)='area'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
|
||||||
|
SELECT 'area','Área',
|
||||||
|
'Área hidrocarburífera administrada como ancla territorial.',
|
||||||
|
true,true,'AREA'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE operational_role='AREA')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='area')
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE asset_types
|
||||||
|
SET operational_role='COMPANY', can_be_root=true, is_active=true, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE lower(code) IN ('empresa','organizacion')
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
|
||||||
|
SELECT 'empresa','Organización',
|
||||||
|
'Entidad jurídica u organización administrada. Su rol operativo se registra mediante relaciones históricas.',
|
||||||
|
true,true,'COMPANY'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE operational_role='COMPANY')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='empresa')
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rows = (await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE operational_role='AREA')::text AS areas,
|
||||||
|
count(*) FILTER (WHERE operational_role='COMPANY')::text AS companies
|
||||||
|
FROM asset_types
|
||||||
|
`)) as Array<{ areas: string; companies: string }>;
|
||||||
|
|
||||||
|
if (Number(rows[0]?.areas ?? 0) < 1 || Number(rows[0]?.companies ?? 0) < 1) {
|
||||||
|
throw new Error('F2.2.1 could not guarantee AREA and COMPANY asset types');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
throw new Error('F2.2.1 base operational types are structural and are not removed automatically');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF221LongReferenceLabels1789754350000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF221LongReferenceLabels1789754350000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// La matriz APLICACION APP contiene denominaciones técnicas descriptivas que
|
||||||
|
// legítimamente superan el límite histórico de 160 caracteres. No se recorta
|
||||||
|
// información de la fuente: ampliamos sólo columnas descriptivas.
|
||||||
|
await queryRunner.query(`ALTER TABLE asset_types ALTER COLUMN name TYPE varchar(500)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE asset_attribute_definitions ALTER COLUMN name TYPE varchar(500)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const rows = (await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
coalesce(max(length(name)),0)::integer AS max_type_name,
|
||||||
|
(SELECT coalesce(max(length(name)),0)::integer FROM asset_attribute_definitions) AS max_attribute_name
|
||||||
|
FROM asset_types
|
||||||
|
`)) as Array<{ max_type_name: number; max_attribute_name: number }>;
|
||||||
|
|
||||||
|
if ((rows[0]?.max_type_name ?? 0) > 160 || (rows[0]?.max_attribute_name ?? 0) > 160) {
|
||||||
|
throw new Error('F2.2.1 no puede reducir etiquetas a 160 caracteres sin pérdida de información');
|
||||||
|
}
|
||||||
|
await queryRunner.query(`ALTER TABLE asset_types ALTER COLUMN name TYPE varchar(160)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE asset_attribute_definitions ALTER COLUMN name TYPE varchar(160)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import { loadF221ExcelSource } from '../../reference-data/f2-2-1-excel-source';
|
||||||
|
|
||||||
|
function slug(value: string, max = 70): string {
|
||||||
|
const normalized = value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '');
|
||||||
|
return (normalized || 'item').slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableCode(prefix: string, ...parts: string[]): string {
|
||||||
|
const hash = createHash('sha1').update(parts.join('|')).digest('hex').slice(0, 8).toUpperCase();
|
||||||
|
const readable = slug(parts[0] ?? 'item', 72).toUpperCase();
|
||||||
|
return `${prefix}-${readable}-${hash}`.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function legalRightType(value: string): string {
|
||||||
|
const normalized = slug(value);
|
||||||
|
if (normalized.includes('explot')) return 'EXPLOITATION_CONCESSION';
|
||||||
|
if (normalized.includes('explor')) return 'EXPLORATION_PERMIT';
|
||||||
|
if (normalized.includes('transport')) return 'TRANSPORT_CONCESSION';
|
||||||
|
return 'OTHER';
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupTargets: Record<string, string[]> = {
|
||||||
|
tanques: ['tanque'],
|
||||||
|
separadores: ['separador'],
|
||||||
|
bomba_zona_de_bombas: ['bomba'],
|
||||||
|
calderas: ['caldera'],
|
||||||
|
drenaje: ['drenaje'],
|
||||||
|
antorcha: ['antorcha'],
|
||||||
|
colectores: ['colector'],
|
||||||
|
sist_electrico_iluminacion: ['sistema_electrico_iluminacion'],
|
||||||
|
defensa_contra_incendios: ['defensa_contra_incendios'],
|
||||||
|
cargadero_y_descargadero_de_camiones: ['cargadero_descargadero'],
|
||||||
|
filtros: ['filtro'],
|
||||||
|
eq_flotacion: ['equipo_flotacion'],
|
||||||
|
baterias_y_plantas: ['bateria', 'planta'],
|
||||||
|
fwko_tratadores_calentadores: ['fwo', 'fwko', 'calentador', 'tratador_termico'],
|
||||||
|
bombeo_mecanico: ['bombeo_mecanico'],
|
||||||
|
bombeo_electreosumergible: ['bombeo_electreosumergible'],
|
||||||
|
bombeo_cavidad_progresiva_pcp: ['bombeo_cavidad_progresiva_pcp'],
|
||||||
|
pozos_surgentes_prod_gas: ['pozos_surgentes_prod_gas'],
|
||||||
|
pozos_inyectores_agua: ['pozos_inyectores_agua'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const referenceAliases: Record<string, string> = {
|
||||||
|
tanque: 'tanque',
|
||||||
|
tanques: 'tanque',
|
||||||
|
separador: 'separador',
|
||||||
|
separadores: 'separador',
|
||||||
|
bombas: 'bomba',
|
||||||
|
bomba: 'bomba',
|
||||||
|
fwo: 'fwo',
|
||||||
|
fwko: 'fwko',
|
||||||
|
calentador: 'calentador',
|
||||||
|
piletas: 'drenaje',
|
||||||
|
pileta: 'drenaje',
|
||||||
|
cargadero_descargadero: 'cargadero_descargadero',
|
||||||
|
generador: 'generador',
|
||||||
|
};
|
||||||
|
|
||||||
|
type IdRow = { id: string };
|
||||||
|
type CountRow = { total: string };
|
||||||
|
|
||||||
|
async function assetIdByCode(queryRunner: QueryRunner, code: string): Promise<string | null> {
|
||||||
|
const rows = (await queryRunner.query(
|
||||||
|
`SELECT id FROM assets WHERE lower(code)=lower($1::text) LIMIT 1`,
|
||||||
|
[code],
|
||||||
|
)) as IdRow[];
|
||||||
|
return rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assetTypeIdByCode(queryRunner: QueryRunner, code: string): Promise<string | null> {
|
||||||
|
const rows = (await queryRunner.query(
|
||||||
|
`SELECT id FROM asset_types WHERE lower(code)=lower($1::text) LIMIT 1`,
|
||||||
|
[code],
|
||||||
|
)) as IdRow[];
|
||||||
|
return rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PhaseF221ReferenceExcelData1789754400000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF221ReferenceExcelData1789754400000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const source = loadF221ExcelSource();
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO source_documents (document_type,document_number,title,issuer,notes)
|
||||||
|
SELECT 'SPREADSHEET','DH-F221-TERRITORIO','Tablas de yacimiento(1).xlsx','Dirección de Hidrocarburos',
|
||||||
|
'Fuente F2.2.1: maestro territorial y operativo. Hoja normalizada cr26e_tabla1.'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM source_documents
|
||||||
|
WHERE document_number='DH-F221-TERRITORIO' AND issuer='Dirección de Hidrocarburos'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO source_documents (document_type,document_number,title,issuer,notes)
|
||||||
|
SELECT 'SPREADSHEET','DH-F221-APP','APLICACION APP(1).xlsx','Dirección de Hidrocarburos',
|
||||||
|
'Fuente F2.2.1: matriz de jerarquía, información y catálogo contextual de hallazgos. Hojas Hoja1 y Hoja2.'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM source_documents
|
||||||
|
WHERE document_number='DH-F221-APP' AND issuer='Dirección de Hidrocarburos'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const territoryDocuments = (await queryRunner.query(
|
||||||
|
`SELECT id FROM source_documents
|
||||||
|
WHERE document_number='DH-F221-TERRITORIO' AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
|
||||||
|
)) as IdRow[];
|
||||||
|
const appDocuments = (await queryRunner.query(
|
||||||
|
`SELECT id FROM source_documents
|
||||||
|
WHERE document_number='DH-F221-APP' AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
|
||||||
|
)) as IdRow[];
|
||||||
|
const territoryDocumentId = territoryDocuments[0]?.id;
|
||||||
|
const appDocumentId = appDocuments[0]?.id;
|
||||||
|
if (!territoryDocumentId || !appDocumentId) {
|
||||||
|
throw new Error('F2.2.1 source documents could not be resolved');
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE asset_types SET can_be_root=true WHERE operational_role IN ('AREA','COMPANY')`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
|
||||||
|
SELECT 'yacimiento','Yacimiento',
|
||||||
|
'Yacimiento físico/operativo perteneciente a un Área. Su operadora se determina por contexto histórico.',
|
||||||
|
false,true,'GENERIC'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='yacimiento')
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
|
||||||
|
SELECT child.id,parent.id
|
||||||
|
FROM asset_types child CROSS JOIN asset_types parent
|
||||||
|
WHERE lower(child.code)='yacimiento' AND parent.operational_role='AREA'
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_attribute_definitions
|
||||||
|
(asset_type_id,code,name,data_type,is_required,is_active,sort_order)
|
||||||
|
SELECT type.id,definition.code,definition.name,'TEXT',false,true,definition.sort_order
|
||||||
|
FROM asset_types type
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('departamento_fuente','Departamento informado por la fuente',10),
|
||||||
|
('tipo_concesion_fuente','Tipo de concesión informado por la fuente',20)
|
||||||
|
) definition(code,name,sort_order)
|
||||||
|
WHERE lower(type.code)='yacimiento'
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_attribute_definitions
|
||||||
|
(asset_type_id,code,name,data_type,is_required,is_active,sort_order)
|
||||||
|
SELECT type.id,'tipo_concesion_fuente','Tipo de concesión informado por la fuente',
|
||||||
|
'TEXT',false,true,40
|
||||||
|
FROM asset_types type
|
||||||
|
WHERE type.operational_role='AREA'
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
const areaCache = new Map<string, string>();
|
||||||
|
const companyCache = new Map<string, string>();
|
||||||
|
const seenRights = new Set<string>();
|
||||||
|
|
||||||
|
for (const row of source.territory) {
|
||||||
|
const areaKey = row.area.trim().toLocaleLowerCase('es');
|
||||||
|
let areaId = areaCache.get(areaKey) ?? null;
|
||||||
|
if (!areaId) {
|
||||||
|
const areaCode = stableCode('AREA', row.area);
|
||||||
|
areaId = await assetIdByCode(queryRunner, areaCode);
|
||||||
|
if (!areaId) {
|
||||||
|
const inserted = (await queryRunner.query(
|
||||||
|
`INSERT INTO assets (
|
||||||
|
asset_type_id,parent_id,code,name,description,information_status,operational_status,
|
||||||
|
data_origin,source_name,source_reference,source_notes,current_version
|
||||||
|
)
|
||||||
|
SELECT type.id,NULL,$1::varchar,$2::varchar,$3::text,
|
||||||
|
'VALIDATED','IN_SERVICE','IMPORT',$4::varchar,$5::varchar,$6::text,0
|
||||||
|
FROM asset_types type
|
||||||
|
WHERE type.operational_role='AREA'
|
||||||
|
LIMIT 1
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
areaCode,
|
||||||
|
row.area,
|
||||||
|
`Área importada desde Tablas de yacimiento. Departamento: ${row.department}.`,
|
||||||
|
'Tablas de yacimiento(1).xlsx',
|
||||||
|
`cr26e_tabla1|AREA|${row.area}`,
|
||||||
|
`Departamento=${row.department}; TipoConcesion=${row.concessionType}`,
|
||||||
|
],
|
||||||
|
)) as IdRow[];
|
||||||
|
areaId = inserted[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!areaId) throw new Error(`Could not resolve area ${row.area}`);
|
||||||
|
areaCache.set(areaKey, areaId);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes)
|
||||||
|
VALUES ($1::uuid,$2::uuid,'SOURCE','Importación F2.2.1 · cr26e_tabla1')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[areaId, territoryDocumentId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [definitionCode, value] of [
|
||||||
|
['departamento', row.department],
|
||||||
|
['tipo_concesion_fuente', row.concessionType],
|
||||||
|
] as const) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_attribute_values (asset_id,definition_id,value)
|
||||||
|
SELECT $1::uuid,definition.id,to_jsonb($3::text)
|
||||||
|
FROM asset_attribute_definitions definition
|
||||||
|
JOIN asset_types type ON type.id=definition.asset_type_id
|
||||||
|
WHERE type.operational_role='AREA'
|
||||||
|
AND lower(definition.code)=lower($2::text)
|
||||||
|
ON CONFLICT (asset_id,definition_id)
|
||||||
|
DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP`,
|
||||||
|
[areaId, definitionCode, value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noOperator = slug(row.operator) === 'sin_empresa_operadora';
|
||||||
|
let companyId: string | null = null;
|
||||||
|
if (!noOperator) {
|
||||||
|
const companyKey = row.operator.trim().toLocaleLowerCase('es');
|
||||||
|
companyId = companyCache.get(companyKey) ?? null;
|
||||||
|
if (!companyId) {
|
||||||
|
const companyCode = stableCode('ORG', row.operator);
|
||||||
|
companyId = await assetIdByCode(queryRunner, companyCode);
|
||||||
|
if (!companyId) {
|
||||||
|
const inserted = (await queryRunner.query(
|
||||||
|
`INSERT INTO assets (
|
||||||
|
asset_type_id,parent_id,code,name,description,information_status,operational_status,
|
||||||
|
data_origin,source_name,source_reference,source_notes,current_version
|
||||||
|
)
|
||||||
|
SELECT type.id,NULL,$1::varchar,$2::varchar,
|
||||||
|
'Organización operadora importada desde Tablas de yacimiento.',
|
||||||
|
'VALIDATED','IN_SERVICE','IMPORT',$3::varchar,$4::varchar,
|
||||||
|
'Empresa/Operadora informada por la fuente',0
|
||||||
|
FROM asset_types type
|
||||||
|
WHERE type.operational_role='COMPANY'
|
||||||
|
LIMIT 1
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
companyCode,
|
||||||
|
row.operator,
|
||||||
|
'Tablas de yacimiento(1).xlsx',
|
||||||
|
`cr26e_tabla1|OPERADORA|${row.operator}`,
|
||||||
|
],
|
||||||
|
)) as IdRow[];
|
||||||
|
companyId = inserted[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!companyId) throw new Error(`Could not resolve operator ${row.operator}`);
|
||||||
|
companyCache.set(companyKey, companyId);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO organization_profiles (asset_id,organization_kind,legal_name)
|
||||||
|
SELECT $1::uuid,
|
||||||
|
CASE WHEN lower($2::text) LIKE 'ute %' OR lower($2::text) LIKE 'ute(%'
|
||||||
|
THEN 'UTE'::organization_kind ELSE 'COMPANY'::organization_kind END,
|
||||||
|
$2::varchar
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM organization_profiles WHERE asset_id=$1::uuid)`,
|
||||||
|
[companyId, row.operator],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes)
|
||||||
|
VALUES ($1::uuid,$2::uuid,'SOURCE','Importación F2.2.1 · cr26e_tabla1')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[companyId, territoryDocumentId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO area_company_relations
|
||||||
|
(area_id,company_id,relation_role,valid_from,start_reason,source_document_id)
|
||||||
|
SELECT $1::uuid,$2::uuid,'OPERATOR',CURRENT_TIMESTAMP,
|
||||||
|
'Importado desde Tablas de yacimiento(1).xlsx',$3::uuid
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM area_company_relations
|
||||||
|
WHERE area_id=$1::uuid AND company_id=$2::uuid
|
||||||
|
AND relation_role='OPERATOR' AND valid_until IS NULL
|
||||||
|
)`,
|
||||||
|
[areaId, companyId, territoryDocumentId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldCode = stableCode('YAC', row.field, row.area);
|
||||||
|
let fieldId = await assetIdByCode(queryRunner, fieldCode);
|
||||||
|
if (!fieldId) {
|
||||||
|
// The operational context is intentionally all-or-nothing. A source row
|
||||||
|
// that explicitly says "Sin Empresa Operadora" stays physically under
|
||||||
|
// its Área but receives no fabricated operator and no half context.
|
||||||
|
const operationalAreaId = companyId ? areaId : null;
|
||||||
|
const inserted = (await queryRunner.query(
|
||||||
|
`INSERT INTO assets (
|
||||||
|
asset_type_id,parent_id,operational_area_id,operator_company_id,code,name,description,
|
||||||
|
information_status,operational_status,data_origin,source_name,source_reference,
|
||||||
|
source_notes,current_version
|
||||||
|
)
|
||||||
|
SELECT type.id,$1::uuid,$2::uuid,$3::uuid,$4::varchar,$5::varchar,$6::text,
|
||||||
|
'VALIDATED','IN_SERVICE','IMPORT',$7::varchar,$8::varchar,$9::text,0
|
||||||
|
FROM asset_types type
|
||||||
|
WHERE lower(type.code)='yacimiento'
|
||||||
|
LIMIT 1
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
areaId,
|
||||||
|
operationalAreaId,
|
||||||
|
companyId,
|
||||||
|
fieldCode,
|
||||||
|
row.field,
|
||||||
|
`Yacimiento del Área ${row.area}.`,
|
||||||
|
'Tablas de yacimiento(1).xlsx',
|
||||||
|
`cr26e_tabla1|YACIMIENTO|${row.field}|AREA|${row.area}`,
|
||||||
|
`Departamento=${row.department}; TipoConcesion=${row.concessionType}; OperadoraFuente=${row.operator}`,
|
||||||
|
],
|
||||||
|
)) as IdRow[];
|
||||||
|
fieldId = inserted[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!fieldId) throw new Error(`Could not resolve field ${row.field} / ${row.area}`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes)
|
||||||
|
VALUES ($1::uuid,$2::uuid,'SOURCE','Importación F2.2.1 · cr26e_tabla1')
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[fieldId, territoryDocumentId],
|
||||||
|
);
|
||||||
|
for (const [definitionCode, value] of [
|
||||||
|
['departamento_fuente', row.department],
|
||||||
|
['tipo_concesion_fuente', row.concessionType],
|
||||||
|
] as const) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_attribute_values (asset_id,definition_id,value)
|
||||||
|
SELECT $1::uuid,definition.id,to_jsonb($3::text)
|
||||||
|
FROM asset_attribute_definitions definition
|
||||||
|
JOIN asset_types type ON type.id=definition.asset_type_id
|
||||||
|
WHERE lower(type.code)='yacimiento'
|
||||||
|
AND lower(definition.code)=lower($2::text)
|
||||||
|
ON CONFLICT (asset_id,definition_id)
|
||||||
|
DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP`,
|
||||||
|
[fieldId, definitionCode, value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightName = `Tipo informado: ${row.concessionType}`;
|
||||||
|
const rightKey = `${areaId}|${slug(row.concessionType)}`;
|
||||||
|
if (!seenRights.has(rightKey)) {
|
||||||
|
seenRights.add(rightKey);
|
||||||
|
const existing = (await queryRunner.query(
|
||||||
|
`SELECT id FROM area_legal_rights
|
||||||
|
WHERE area_id=$1::uuid AND lower(name)=lower($2::text)
|
||||||
|
AND source_document_id=$3::uuid LIMIT 1`,
|
||||||
|
[areaId, rightName, territoryDocumentId],
|
||||||
|
)) as IdRow[];
|
||||||
|
if (!existing[0]?.id) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO area_legal_rights
|
||||||
|
(area_id,right_type,name,status,source_document_id,notes)
|
||||||
|
VALUES ($1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5::text)`,
|
||||||
|
[
|
||||||
|
areaId,
|
||||||
|
legalRightType(row.concessionType),
|
||||||
|
rightName,
|
||||||
|
territoryDocumentId,
|
||||||
|
`Importado desde cr26e_tabla1. Departamento: ${row.department}.`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLevelTypes = new Set(source.types.map((entry) => entry.code));
|
||||||
|
for (const pair of source.parentPairs) topLevelTypes.delete(pair.child);
|
||||||
|
|
||||||
|
const allTypeDefinitions = new Map(source.types.map((entry) => [entry.code, entry.name]));
|
||||||
|
for (const targets of Object.values(groupTargets)) {
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!allTypeDefinitions.has(target)) {
|
||||||
|
allTypeDefinitions.set(target, target.replaceAll('_', ' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [typeCode, typeName] of allTypeDefinitions) {
|
||||||
|
if (!(await assetTypeIdByCode(queryRunner, typeCode))) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
|
||||||
|
VALUES ($1::varchar,$2::varchar,
|
||||||
|
'Tipo incorporado desde APLICACION APP(1).xlsx · F2.2.1',false,true,'GENERIC')`,
|
||||||
|
[typeCode, typeName],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO finding_catalog_asset_type_profiles (asset_type_id,reason)
|
||||||
|
SELECT id,'Aplicabilidad inicial importada desde APLICACION APP(1).xlsx'
|
||||||
|
FROM asset_types WHERE lower(code)=lower($1::text)
|
||||||
|
ON CONFLICT (asset_type_id) DO NOTHING`,
|
||||||
|
[typeCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const typeCode of topLevelTypes) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
|
||||||
|
SELECT child.id,parent.id FROM asset_types child CROSS JOIN asset_types parent
|
||||||
|
WHERE lower(child.code)=lower($1::text) AND lower(parent.code)='yacimiento'
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[typeCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const pair of source.parentPairs) {
|
||||||
|
if (pair.child === pair.parent) continue;
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
|
||||||
|
SELECT child.id,parent.id FROM asset_types child CROSS JOIN asset_types parent
|
||||||
|
WHERE lower(child.code)=lower($1::text) AND lower(parent.code)=lower($2::text)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[pair.child, pair.parent],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const targets of Object.values(groupTargets)) {
|
||||||
|
for (const typeCode of targets) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
|
||||||
|
SELECT child.id,parent.id FROM asset_types child CROSS JOIN asset_types parent
|
||||||
|
WHERE lower(child.code)=lower($1::text) AND lower(parent.code)='yacimiento'
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[typeCode],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of source.attributes) {
|
||||||
|
let sortOrder = 100;
|
||||||
|
for (const label of entry.labels) {
|
||||||
|
const attributeCode = slug(label, 72);
|
||||||
|
const typeId = await assetTypeIdByCode(queryRunner, entry.type);
|
||||||
|
if (!typeId) continue;
|
||||||
|
const existing = (await queryRunner.query(
|
||||||
|
`SELECT id FROM asset_attribute_definitions
|
||||||
|
WHERE asset_type_id=$1::uuid AND lower(code)=lower($2::text) LIMIT 1`,
|
||||||
|
[typeId, attributeCode],
|
||||||
|
)) as IdRow[];
|
||||||
|
if (!existing[0]?.id) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_attribute_definitions
|
||||||
|
(asset_type_id,code,name,data_type,is_required,is_active,sort_order)
|
||||||
|
VALUES ($1::uuid,$2::varchar,$3::varchar,'TEXT',false,true,$4::integer)`,
|
||||||
|
[typeId, attributeCode, label, sortOrder],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sortOrder += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_categories (code,name,sort_order,is_active)
|
||||||
|
SELECT 'APP26','Aplicación APP 2026',260,true
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)='app26')
|
||||||
|
`);
|
||||||
|
const categoryRows = (await queryRunner.query(
|
||||||
|
`SELECT id FROM finding_categories WHERE lower(code)='app26' LIMIT 1`,
|
||||||
|
)) as IdRow[];
|
||||||
|
const categoryId = categoryRows[0]?.id;
|
||||||
|
if (!categoryId) throw new Error('Could not resolve APP26 finding category');
|
||||||
|
|
||||||
|
const titlesByType = new Map<string, Set<string>>();
|
||||||
|
const addTitle = (typeCode: string, title: string): void => {
|
||||||
|
const cleanTitle = title.trim();
|
||||||
|
if (!cleanTitle || /^idem\b/i.test(cleanTitle)) return;
|
||||||
|
const set = titlesByType.get(typeCode) ?? new Set<string>();
|
||||||
|
set.add(cleanTitle);
|
||||||
|
titlesByType.set(typeCode, set);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const entry of source.directFindings) {
|
||||||
|
for (const title of entry.titles) addTitle(entry.type, title);
|
||||||
|
}
|
||||||
|
for (const group of source.groups) {
|
||||||
|
const targets = groupTargets[slug(group.name)] ?? [slug(group.name)];
|
||||||
|
for (const target of targets) {
|
||||||
|
for (const item of group.items) addTitle(target, item.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let pass = 0; pass < 3; pass += 1) {
|
||||||
|
for (const entry of source.idemRefs) {
|
||||||
|
for (const rawReference of entry.refs) {
|
||||||
|
const reference = referenceAliases[slug(rawReference)] ?? slug(rawReference);
|
||||||
|
for (const title of titlesByType.get(reference) ?? []) addTitle(entry.type, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allTitles = [
|
||||||
|
...new Set([...titlesByType.values()].flatMap((titles) => [...titles])),
|
||||||
|
].sort((a, b) => a.localeCompare(b, 'es'));
|
||||||
|
|
||||||
|
const itemIdByTitle = new Map<string, string>();
|
||||||
|
let sourceNumber = 1;
|
||||||
|
for (const title of allTitles) {
|
||||||
|
const itemCode = `APP26-${createHash('sha1').update(title).digest('hex').slice(0, 12).toUpperCase()}`;
|
||||||
|
let itemRows = (await queryRunner.query(
|
||||||
|
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::text) LIMIT 1`,
|
||||||
|
[itemCode],
|
||||||
|
)) as IdRow[];
|
||||||
|
if (!itemRows[0]?.id) {
|
||||||
|
itemRows = (await queryRunner.query(
|
||||||
|
`INSERT INTO finding_catalog_items
|
||||||
|
(category_id,code,source_number,title,import_note,revision,is_active)
|
||||||
|
VALUES ($1::uuid,$2::varchar,$3::integer,$4::varchar,
|
||||||
|
'Importado desde APLICACION APP(1).xlsx · F2.2.1',1,true)
|
||||||
|
RETURNING id`,
|
||||||
|
[categoryId, itemCode, sourceNumber, title],
|
||||||
|
)) as IdRow[];
|
||||||
|
}
|
||||||
|
const itemId = itemRows[0]?.id;
|
||||||
|
if (itemId) {
|
||||||
|
itemIdByTitle.set(title, itemId);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO finding_catalog_item_versions
|
||||||
|
(item_id,revision,snapshot,actor_username)
|
||||||
|
SELECT item.id,item.revision,
|
||||||
|
jsonb_build_object(
|
||||||
|
'id',item.id,'categoryId',category.id,'categoryCode',category.code,
|
||||||
|
'categoryName',category.name,'code',item.code,'sourceNumber',item.source_number,
|
||||||
|
'title',item.title,'legalBasis',item.legal_basis,'glossary',item.glossary,
|
||||||
|
'importNote',item.import_note,'revision',item.revision,'isActive',item.is_active
|
||||||
|
),
|
||||||
|
'migration:F2.2.1'
|
||||||
|
FROM finding_catalog_items item
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
WHERE item.id=$1::uuid
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM finding_catalog_item_versions version
|
||||||
|
WHERE version.item_id=item.id AND version.revision=item.revision
|
||||||
|
)`,
|
||||||
|
[itemId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
sourceNumber += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [typeCode, titles] of titlesByType) {
|
||||||
|
const assetTypeId = await assetTypeIdByCode(queryRunner, typeCode);
|
||||||
|
if (!assetTypeId) continue;
|
||||||
|
for (const title of titles) {
|
||||||
|
const catalogItemId = itemIdByTitle.get(title);
|
||||||
|
if (!catalogItemId) continue;
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO finding_catalog_item_asset_types (catalog_item_id,asset_type_id)
|
||||||
|
VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING`,
|
||||||
|
[catalogItemId, assetTypeId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes)
|
||||||
|
SELECT asset.id,$1::uuid,'MENTIONS',
|
||||||
|
'El tipo/hallazgo del elemento se definió con la matriz APLICACION APP(1).xlsx'
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE lower(type.code)='yacimiento'
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
[appDocumentId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const importedYacimientos = (await queryRunner.query(
|
||||||
|
`SELECT count(*)::text AS total
|
||||||
|
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE asset.data_origin='IMPORT'
|
||||||
|
AND asset.source_name='Tablas de yacimiento(1).xlsx'
|
||||||
|
AND lower(type.code)='yacimiento'`,
|
||||||
|
)) as CountRow[];
|
||||||
|
if (Number(importedYacimientos[0]?.total ?? 0) !== source.territory.length) {
|
||||||
|
throw new Error(
|
||||||
|
`F2.2.1 verification failed: expected ${source.territory.length} imported yacimientos, found ${importedYacimientos[0]?.total ?? '0'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedCatalog = (await queryRunner.query(
|
||||||
|
`SELECT count(*)::text AS total
|
||||||
|
FROM finding_catalog_items item
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
WHERE lower(category.code)='app26'`,
|
||||||
|
)) as CountRow[];
|
||||||
|
if (Number(importedCatalog[0]?.total ?? 0) !== allTitles.length) {
|
||||||
|
throw new Error(
|
||||||
|
`F2.2.1 verification failed: expected ${allTitles.length} APP26 findings, found ${importedCatalog[0]?.total ?? '0'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const halfContexts = (await queryRunner.query(
|
||||||
|
`SELECT count(*)::text AS total FROM assets
|
||||||
|
WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL)`,
|
||||||
|
)) as CountRow[];
|
||||||
|
if (Number(halfContexts[0]?.total ?? 0) !== 0) {
|
||||||
|
throw new Error('F2.2.1 verification failed: an imported asset has an incomplete operational context');
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[F2.2.1] imported territory=${source.territory.length}; yacimientos=${importedYacimientos[0]?.total}; catalog=${importedCatalog[0]?.total}; halfContexts=0`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
throw new Error(
|
||||||
|
'F2.2.1 contiene maestros territoriales y catálogo de referencia; no se revierte destructivamente. Restaurar backup PRE si fuera necesario.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF221ReferenceBaselines1789758000000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF221ReferenceBaselines1789758000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_versions (
|
||||||
|
asset_id,version_number,change_type,changed_fields,snapshot,source
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
asset.id,
|
||||||
|
1,
|
||||||
|
'BASELINE',
|
||||||
|
ARRAY['assetTypeId','parentId','operationalAreaId','operatorCompanyId','code','name','informationStatus','operationalStatus','dataOrigin','sourceName','sourceReference'],
|
||||||
|
jsonb_build_object(
|
||||||
|
'id',asset.id,
|
||||||
|
'assetTypeId',asset.asset_type_id,
|
||||||
|
'parentId',asset.parent_id,
|
||||||
|
'operationalAreaId',asset.operational_area_id,
|
||||||
|
'operatorCompanyId',asset.operator_company_id,
|
||||||
|
'code',asset.code,
|
||||||
|
'name',asset.name,
|
||||||
|
'commonName',asset.common_name,
|
||||||
|
'description',asset.description,
|
||||||
|
'informationStatus',asset.information_status,
|
||||||
|
'operationalStatus',asset.operational_status,
|
||||||
|
'dataOrigin',asset.data_origin,
|
||||||
|
'sourceName',asset.source_name,
|
||||||
|
'sourceReference',asset.source_reference,
|
||||||
|
'sourceNotes',asset.source_notes
|
||||||
|
),
|
||||||
|
'SYSTEM'
|
||||||
|
FROM assets asset
|
||||||
|
WHERE asset.data_origin='IMPORT'
|
||||||
|
AND asset.source_name='Tablas de yacimiento(1).xlsx'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM asset_versions version
|
||||||
|
WHERE version.asset_id=asset.id AND version.version_number=1
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE assets asset
|
||||||
|
SET current_version=GREATEST(asset.current_version,1),updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE asset.data_origin='IMPORT'
|
||||||
|
AND asset.source_name='Tablas de yacimiento(1).xlsx'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM asset_versions version
|
||||||
|
WHERE version.asset_id=asset.id AND version.version_number=1
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
throw new Error('F2.2.1 no elimina baselines históricos del maestro importado. Restaurar backup PRE si fuera necesario.');
|
||||||
|
}
|
||||||
|
}
|
||||||
+359
@@ -0,0 +1,359 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import {
|
||||||
|
F31_FINDING_GROUPS,
|
||||||
|
F31_INSTALLATION_FAMILIES,
|
||||||
|
F31_SUBINSTALLATION_FAMILIES,
|
||||||
|
F31_UNIVERSAL_FINDINGS,
|
||||||
|
} from '../../reference-data/f3-1-inventory-excel';
|
||||||
|
|
||||||
|
function quoteIdentifier(identifier: string): string {
|
||||||
|
return `"${identifier.replaceAll('"', '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingKey(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingCode(title: string): string {
|
||||||
|
return `APP26R2-${createHash('sha1').update(findingKey(title)).digest('hex').slice(0, 12).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdRow = { id: string };
|
||||||
|
type CountRow = { total: string };
|
||||||
|
|
||||||
|
export class PhaseF31InventoryStructureCatalog1789844400000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF31InventoryStructureCatalog1789844400000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO source_documents (document_type,document_number,title,issuer,notes)
|
||||||
|
SELECT 'SPREADSHEET','DH-F31-APP','APLICACION APP(2).xlsx','Dirección de Hidrocarburos',
|
||||||
|
'F3.1: revisión estructural. Hoja1 define Instalaciones/Subinstalaciones; Hoja2 define catálogo contextual.'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM source_documents
|
||||||
|
WHERE document_number='DH-F31-APP' AND issuer='Dirección de Hidrocarburos'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role)
|
||||||
|
SELECT 'subinstalacion','Subinstalación',
|
||||||
|
'Unidad física subordinada a una Instalación. Su familia técnica determina el catálogo contextual de Hallazgos.',
|
||||||
|
false,true,'GENERIC'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='subinstalacion')
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_type_parent_rules (child_type_id,parent_type_id)
|
||||||
|
SELECT child.id,parent.id
|
||||||
|
FROM asset_types child CROSS JOIN asset_types parent
|
||||||
|
WHERE lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion'
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE inventory_families (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code varchar(120) NOT NULL UNIQUE,
|
||||||
|
name varchar(240) NOT NULL,
|
||||||
|
level varchar(24) NOT NULL,
|
||||||
|
legacy_type_code varchar(120),
|
||||||
|
information_labels jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
source_reference varchar(240),
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_inventory_families_level CHECK (level IN ('INSTALLATION','SUBINSTALLATION')),
|
||||||
|
CONSTRAINT chk_inventory_families_information_labels CHECK (jsonb_typeof(information_labels)='array')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_inventory_families_level_active
|
||||||
|
ON inventory_families(level,is_active,name)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE inventory_family_parent_rules (
|
||||||
|
child_family_id uuid PRIMARY KEY,
|
||||||
|
parent_family_id uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_inventory_family_parent_child FOREIGN KEY (child_family_id)
|
||||||
|
REFERENCES inventory_families(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_inventory_family_parent_parent FOREIGN KEY (parent_family_id)
|
||||||
|
REFERENCES inventory_families(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT chk_inventory_family_parent_distinct CHECK (child_family_id <> parent_family_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_inventory_family_parent_parent
|
||||||
|
ON inventory_family_parent_rules(parent_family_id,child_family_id)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE assets ADD COLUMN inventory_family_id uuid
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE assets ADD CONSTRAINT fk_assets_inventory_family
|
||||||
|
FOREIGN KEY (inventory_family_id) REFERENCES inventory_families(id) ON DELETE RESTRICT
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_assets_inventory_family ON assets(inventory_family_id)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE finding_catalog_item_inventory_families (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
catalog_item_id uuid NOT NULL,
|
||||||
|
inventory_family_id uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_finding_catalog_item_inventory_family UNIQUE (catalog_item_id,inventory_family_id),
|
||||||
|
CONSTRAINT fk_finding_catalog_family_item FOREIGN KEY (catalog_item_id)
|
||||||
|
REFERENCES finding_catalog_items(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_finding_catalog_family_family FOREIGN KEY (inventory_family_id)
|
||||||
|
REFERENCES inventory_families(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_finding_catalog_family_family
|
||||||
|
ON finding_catalog_item_inventory_families(inventory_family_id,catalog_item_id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const family of F31_INSTALLATION_FAMILIES) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_families
|
||||||
|
(code,name,level,legacy_type_code,information_labels,source_reference,is_active)
|
||||||
|
VALUES ($1,$2,'INSTALLATION',$3,'[]'::jsonb,$4,true)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name=EXCLUDED.name,
|
||||||
|
legacy_type_code=EXCLUDED.legacy_type_code,
|
||||||
|
source_reference=EXCLUDED.source_reference,
|
||||||
|
is_active=true,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [family.code, family.name, family.legacyTypeCode, `APLICACION APP(2).xlsx|Hoja1|fila:${family.sourceRow}`]);
|
||||||
|
}
|
||||||
|
for (const family of F31_SUBINSTALLATION_FAMILIES) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_families
|
||||||
|
(code,name,level,legacy_type_code,information_labels,source_reference,is_active)
|
||||||
|
VALUES ($1,$2,'SUBINSTALLATION',$3,$4::jsonb,$5,true)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name=EXCLUDED.name,
|
||||||
|
legacy_type_code=EXCLUDED.legacy_type_code,
|
||||||
|
information_labels=EXCLUDED.information_labels,
|
||||||
|
source_reference=EXCLUDED.source_reference,
|
||||||
|
is_active=true,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [
|
||||||
|
family.code,
|
||||||
|
family.name,
|
||||||
|
family.legacyTypeCode,
|
||||||
|
JSON.stringify(family.informationLabels),
|
||||||
|
`APLICACION APP(2).xlsx|Hoja1|fila:${family.sourceRow}`,
|
||||||
|
]);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
|
||||||
|
SELECT child.id,parent.id
|
||||||
|
FROM inventory_families child CROSS JOIN inventory_families parent
|
||||||
|
WHERE child.code=$1 AND parent.code=$2
|
||||||
|
ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id
|
||||||
|
`, [family.code, family.parentCode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE finding_categories SET is_active=false, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE lower(code)='app26'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_categories(code,name,sort_order,is_active)
|
||||||
|
SELECT 'APP26R2','Aplicación APP 2026 · revisión estructural',261,true
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)='app26r2')
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE finding_categories
|
||||||
|
SET name='Aplicación APP 2026 · revisión estructural', sort_order=261,
|
||||||
|
is_active=true, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE lower(code)='app26r2'
|
||||||
|
`);
|
||||||
|
const categoryRows = (await queryRunner.query(
|
||||||
|
`SELECT id FROM finding_categories WHERE lower(code)='app26r2' LIMIT 1`,
|
||||||
|
)) as IdRow[];
|
||||||
|
const categoryId = categoryRows[0]?.id;
|
||||||
|
if (!categoryId) throw new Error('F3.1 could not resolve APP26R2 category');
|
||||||
|
|
||||||
|
const groupByKey = new Map(F31_FINDING_GROUPS.map((group) => [group.key, group]));
|
||||||
|
const titlesByFamily = new Map<string, Map<string,string>>();
|
||||||
|
const addFamilyTitle = (familyCode: string, title: string): void => {
|
||||||
|
const clean = title.trim();
|
||||||
|
if (!clean || /^idem\b/i.test(clean) || clean.toUpperCase()==='HALLAZGOS') return;
|
||||||
|
const key = findingKey(clean);
|
||||||
|
const map = titlesByFamily.get(familyCode) ?? new Map<string,string>();
|
||||||
|
if (!map.has(key)) map.set(key, clean);
|
||||||
|
titlesByFamily.set(familyCode, map);
|
||||||
|
};
|
||||||
|
const seedFamily = (family: { code: string; groupKeys: string[]; directFindings: string[] }): void => {
|
||||||
|
for (const title of F31_UNIVERSAL_FINDINGS) addFamilyTitle(family.code, title);
|
||||||
|
for (const groupKey of family.groupKeys) {
|
||||||
|
const group = groupByKey.get(groupKey);
|
||||||
|
if (!group) throw new Error(`F3.1 missing finding group ${groupKey}`);
|
||||||
|
for (const title of group.items) addFamilyTitle(family.code, title);
|
||||||
|
}
|
||||||
|
for (const title of family.directFindings) addFamilyTitle(family.code, title);
|
||||||
|
};
|
||||||
|
for (const family of F31_INSTALLATION_FAMILIES) seedFamily(family);
|
||||||
|
for (const family of F31_SUBINSTALLATION_FAMILIES) seedFamily(family);
|
||||||
|
|
||||||
|
const allTitles = new Map<string,string>();
|
||||||
|
for (const titles of titlesByFamily.values()) {
|
||||||
|
for (const [key,title] of titles) if (!allTitles.has(key)) allTitles.set(key,title);
|
||||||
|
}
|
||||||
|
const orderedTitles = [...allTitles.entries()].sort((a,b) => a[1].localeCompare(b[1],'es'));
|
||||||
|
const itemIdByKey = new Map<string,string>();
|
||||||
|
let sourceNumber = 1;
|
||||||
|
for (const [key,title] of orderedTitles) {
|
||||||
|
const code = findingCode(title);
|
||||||
|
let rows = (await queryRunner.query(
|
||||||
|
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::text) LIMIT 1`,
|
||||||
|
[code],
|
||||||
|
)) as IdRow[];
|
||||||
|
if (!rows[0]?.id) {
|
||||||
|
rows = (await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_items
|
||||||
|
(category_id,code,source_number,title,import_note,revision,is_active)
|
||||||
|
VALUES ($1::uuid,$2::varchar,$3::integer,$4::varchar,$5::text,1,true)
|
||||||
|
RETURNING id
|
||||||
|
`, [
|
||||||
|
categoryId,
|
||||||
|
code,
|
||||||
|
sourceNumber,
|
||||||
|
title,
|
||||||
|
'APLICACION APP(2).xlsx · F3.1 · catálogo estructural revisado',
|
||||||
|
])) as IdRow[];
|
||||||
|
} else {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE finding_catalog_items SET
|
||||||
|
category_id=$2::uuid,source_number=$3::integer,title=$4::varchar,
|
||||||
|
import_note=$5::text,is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1::uuid
|
||||||
|
`, [rows[0].id, categoryId, sourceNumber, title, 'APLICACION APP(2).xlsx · F3.1 · catálogo estructural revisado']);
|
||||||
|
}
|
||||||
|
const itemId = rows[0]?.id;
|
||||||
|
if (!itemId) throw new Error(`F3.1 could not create catalog item ${title}`);
|
||||||
|
itemIdByKey.set(key,itemId);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_item_versions(item_id,revision,snapshot,actor_username)
|
||||||
|
SELECT item.id,item.revision,
|
||||||
|
jsonb_build_object(
|
||||||
|
'id',item.id,'categoryId',category.id,'categoryCode',category.code,
|
||||||
|
'categoryName',category.name,'code',item.code,'sourceNumber',item.source_number,
|
||||||
|
'title',item.title,'legalBasis',item.legal_basis,'glossary',item.glossary,
|
||||||
|
'importNote',item.import_note,'revision',item.revision,'isActive',item.is_active
|
||||||
|
),'migration:F3.1'
|
||||||
|
FROM finding_catalog_items item
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
WHERE item.id=$1::uuid
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM finding_catalog_item_versions version
|
||||||
|
WHERE version.item_id=item.id AND version.revision=item.revision
|
||||||
|
)
|
||||||
|
`, [itemId]);
|
||||||
|
sourceNumber += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`DELETE FROM finding_catalog_item_inventory_families`);
|
||||||
|
const familyMeta = [
|
||||||
|
...F31_INSTALLATION_FAMILIES,
|
||||||
|
...F31_SUBINSTALLATION_FAMILIES,
|
||||||
|
];
|
||||||
|
for (const family of familyMeta) {
|
||||||
|
const familyRows = (await queryRunner.query(
|
||||||
|
`SELECT id FROM inventory_families WHERE code=$1 LIMIT 1`,
|
||||||
|
[family.code],
|
||||||
|
)) as IdRow[];
|
||||||
|
const familyId = familyRows[0]?.id;
|
||||||
|
if (!familyId) throw new Error(`F3.1 missing inventory family ${family.code}`);
|
||||||
|
const titles = titlesByFamily.get(family.code) ?? new Map<string,string>();
|
||||||
|
for (const key of titles.keys()) {
|
||||||
|
const itemId = itemIdByKey.get(key);
|
||||||
|
if (!itemId) continue;
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
|
||||||
|
VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING
|
||||||
|
`, [itemId,familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyRows = (await queryRunner.query(
|
||||||
|
`SELECT id FROM asset_types WHERE lower(code)=lower($1::text) LIMIT 1`,
|
||||||
|
[family.legacyTypeCode],
|
||||||
|
)) as IdRow[];
|
||||||
|
const legacyTypeId = legacyRows[0]?.id;
|
||||||
|
if (legacyTypeId) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
|
||||||
|
VALUES ($1::uuid,'F3.1: compatibilidad con activos técnicos históricos usando catálogo APP26R2')
|
||||||
|
ON CONFLICT (asset_type_id) DO UPDATE SET
|
||||||
|
reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [legacyTypeId]);
|
||||||
|
for (const key of titles.keys()) {
|
||||||
|
const itemId = itemIdByKey.get(key);
|
||||||
|
if (!itemId) continue;
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_item_asset_types(catalog_item_id,asset_type_id)
|
||||||
|
VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING
|
||||||
|
`, [itemId,legacyTypeId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
|
||||||
|
SELECT id,'F3.1: las altas estructurales usan familia técnica contextual; sin familia no se sugiere catálogo.'
|
||||||
|
FROM asset_types WHERE lower(code) IN ('instalacion','subinstalacion')
|
||||||
|
ON CONFLICT (asset_type_id) DO UPDATE SET
|
||||||
|
reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`);
|
||||||
|
|
||||||
|
const appRole = process.env.DB_APP_USER;
|
||||||
|
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
|
||||||
|
const roleRows = (await queryRunner.query('SELECT 1 FROM pg_roles WHERE rolname=$1',[appRole])) as unknown[];
|
||||||
|
if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist');
|
||||||
|
const applicationRole = quoteIdentifier(appRole);
|
||||||
|
await queryRunner.query(`GRANT SELECT ON TABLE inventory_families TO ${applicationRole}`);
|
||||||
|
await queryRunner.query(`GRANT SELECT ON TABLE inventory_family_parent_rules TO ${applicationRole}`);
|
||||||
|
await queryRunner.query(`GRANT SELECT ON TABLE finding_catalog_item_inventory_families TO ${applicationRole}`);
|
||||||
|
|
||||||
|
const familyCounts = (await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE level='INSTALLATION')::text AS installations,
|
||||||
|
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')::text AS subinstallations
|
||||||
|
FROM inventory_families WHERE is_active=true
|
||||||
|
`)) as Array<{ installations: string; subinstallations: string }>;
|
||||||
|
if (Number(familyCounts[0]?.installations ?? 0) !== F31_INSTALLATION_FAMILIES.length) {
|
||||||
|
throw new Error('F3.1 verification failed: installation-family count mismatch');
|
||||||
|
}
|
||||||
|
if (Number(familyCounts[0]?.subinstallations ?? 0) !== F31_SUBINSTALLATION_FAMILIES.length) {
|
||||||
|
throw new Error('F3.1 verification failed: subinstallation-family count mismatch');
|
||||||
|
}
|
||||||
|
const catalogCounts = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::text AS total
|
||||||
|
FROM finding_catalog_items item
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
WHERE lower(category.code)='app26r2' AND item.is_active=true
|
||||||
|
`)) as CountRow[];
|
||||||
|
if (Number(catalogCounts[0]?.total ?? 0) !== orderedTitles.length) {
|
||||||
|
throw new Error('F3.1 verification failed: APP26R2 catalog count mismatch');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[F3.1] families=${F31_INSTALLATION_FAMILIES.length}+${F31_SUBINSTALLATION_FAMILIES.length}; groups=${F31_FINDING_GROUPS.length}; catalog=${orderedTitles.length}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
throw new Error(
|
||||||
|
'F3.1 recarga catálogo y agrega familias estructurales; no se revierte destructivamente. Restaurar backup PRE si fuera necesario.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF31FamilyCatalogSync1789844500000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF31FamilyCatalogSync1789844500000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM finding_catalog_asset_overrides
|
||||||
|
WHERE asset_id=NEW.id
|
||||||
|
AND reason LIKE 'F3.1 familia técnica:%';
|
||||||
|
|
||||||
|
IF NEW.inventory_family_id IS NOT NULL THEN
|
||||||
|
INSERT INTO finding_catalog_asset_overrides (
|
||||||
|
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
NEW.id,mapping.catalog_item_id,true,
|
||||||
|
'F3.1 familia técnica: catálogo contextual automático',
|
||||||
|
NEW.created_by,NEW.updated_by
|
||||||
|
FROM finding_catalog_item_inventory_families mapping
|
||||||
|
WHERE mapping.inventory_family_id=NEW.inventory_family_id
|
||||||
|
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
|
||||||
|
is_enabled=true,
|
||||||
|
reason='F3.1 familia técnica: catálogo contextual automático',
|
||||||
|
updated_by=NEW.updated_by,
|
||||||
|
updated_at=CURRENT_TIMESTAMP;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TRIGGER trg_assets_inventory_family_catalog
|
||||||
|
AFTER INSERT OR UPDATE OF inventory_family_id ON assets
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION sync_asset_inventory_family_catalog()
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP='DELETE' THEN
|
||||||
|
DELETE FROM finding_catalog_asset_overrides override_record
|
||||||
|
USING assets asset
|
||||||
|
WHERE override_record.asset_id=asset.id
|
||||||
|
AND asset.inventory_family_id=OLD.inventory_family_id
|
||||||
|
AND override_record.catalog_item_id=OLD.catalog_item_id
|
||||||
|
AND override_record.reason LIKE 'F3.1 familia técnica:%';
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO finding_catalog_asset_overrides (
|
||||||
|
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
asset.id,NEW.catalog_item_id,true,
|
||||||
|
'F3.1 familia técnica: catálogo contextual automático',
|
||||||
|
asset.created_by,asset.updated_by
|
||||||
|
FROM assets asset
|
||||||
|
WHERE asset.inventory_family_id=NEW.inventory_family_id
|
||||||
|
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
|
||||||
|
is_enabled=true,
|
||||||
|
reason='F3.1 familia técnica: catálogo contextual automático',
|
||||||
|
updated_at=CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TRIGGER trg_inventory_family_mapping_assets
|
||||||
|
AFTER INSERT OR DELETE ON finding_catalog_item_inventory_families
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION sync_inventory_family_mapping_assets()
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_asset_overrides (
|
||||||
|
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
asset.id,mapping.catalog_item_id,true,
|
||||||
|
'F3.1 familia técnica: catálogo contextual automático',
|
||||||
|
asset.created_by,asset.updated_by
|
||||||
|
FROM assets asset
|
||||||
|
JOIN finding_catalog_item_inventory_families mapping
|
||||||
|
ON mapping.inventory_family_id=asset.inventory_family_id
|
||||||
|
WHERE asset.inventory_family_id IS NOT NULL
|
||||||
|
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
|
||||||
|
is_enabled=true,
|
||||||
|
reason='F3.1 familia técnica: catálogo contextual automático',
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_mapping_assets ON finding_catalog_item_inventory_families');
|
||||||
|
await queryRunner.query('DROP FUNCTION IF EXISTS sync_inventory_family_mapping_assets()');
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_assets_inventory_family_catalog ON assets');
|
||||||
|
await queryRunner.query('DROP FUNCTION IF EXISTS sync_asset_inventory_family_catalog()');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF31AssetChronologicalMerge1789844600000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF31AssetChronologicalMerge1789844600000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE asset_merges (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
source_asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
|
||||||
|
canonical_asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
|
||||||
|
reason text NOT NULL,
|
||||||
|
merged_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
merged_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
source varchar(32) NOT NULL,
|
||||||
|
request_id varchar(128) NULL,
|
||||||
|
source_snapshot jsonb NOT NULL,
|
||||||
|
canonical_snapshot jsonb NOT NULL,
|
||||||
|
CONSTRAINT chk_asset_merges_distinct CHECK (source_asset_id <> canonical_asset_id),
|
||||||
|
CONSTRAINT uq_asset_merges_source UNIQUE (source_asset_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_asset_merges_canonical ON asset_merges(canonical_asset_id)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_asset_merges_merged_at ON asset_merges(merged_at)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION protect_asset_merges_append_only()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'asset_merges es append-only; una fusión no puede editarse ni borrarse';
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TRIGGER trg_asset_merges_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON asset_merges
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION protect_asset_merges_append_only()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_merges_append_only ON asset_merges');
|
||||||
|
await queryRunner.query('DROP FUNCTION IF EXISTS protect_asset_merges_append_only()');
|
||||||
|
await queryRunner.query('DROP TABLE IF EXISTS asset_merges');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF31UserProfile1789844700000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF31UserProfile1789844700000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE users ADD COLUMN dni varchar(32) NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE users ADD COLUMN phone varchar(40) NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE users ADD COLUMN job_title varchar(160) NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE users ADD COLUMN employee_number varchar(80) NULL`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX uq_users_dni_not_null
|
||||||
|
ON users(dni) WHERE dni IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION enforce_inspector_email_on_role()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE selected_role_code varchar(80);
|
||||||
|
DECLARE selected_email varchar(320);
|
||||||
|
BEGIN
|
||||||
|
SELECT code INTO selected_role_code FROM roles WHERE id=NEW.role_id;
|
||||||
|
IF selected_role_code='inspector' THEN
|
||||||
|
SELECT email INTO selected_email FROM users WHERE id=NEW.user_id;
|
||||||
|
IF selected_email IS NULL OR btrim(selected_email)='' THEN
|
||||||
|
RAISE EXCEPTION 'INSPECTOR_EMAIL_REQUIRED';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TRIGGER trg_user_roles_inspector_email
|
||||||
|
BEFORE INSERT OR UPDATE ON user_roles
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION enforce_inspector_email_on_role()
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION prevent_inspector_email_removal()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF (NEW.email IS NULL OR btrim(NEW.email)='') AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_roles user_role
|
||||||
|
JOIN roles role ON role.id=user_role.role_id
|
||||||
|
WHERE user_role.user_id=NEW.id AND role.code='inspector'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'INSPECTOR_EMAIL_REQUIRED';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TRIGGER trg_users_inspector_email
|
||||||
|
BEFORE UPDATE OF email ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION prevent_inspector_email_removal()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_users_inspector_email ON users');
|
||||||
|
await queryRunner.query('DROP FUNCTION IF EXISTS prevent_inspector_email_removal()');
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_user_roles_inspector_email ON user_roles');
|
||||||
|
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_inspector_email_on_role()');
|
||||||
|
await queryRunner.query('DROP INDEX IF EXISTS uq_users_dni_not_null');
|
||||||
|
await queryRunner.query('ALTER TABLE users DROP COLUMN IF EXISTS employee_number');
|
||||||
|
await queryRunner.query('ALTER TABLE users DROP COLUMN IF EXISTS job_title');
|
||||||
|
await queryRunner.query('ALTER TABLE users DROP COLUMN IF EXISTS phone');
|
||||||
|
await queryRunner.query('ALTER TABLE users DROP COLUMN IF EXISTS dni');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import {
|
||||||
|
F31_INSTALLATION_FAMILIES,
|
||||||
|
F31_UNIVERSAL_FINDINGS,
|
||||||
|
} from '../../reference-data/f3-1-inventory-excel';
|
||||||
|
|
||||||
|
const INSTALLATION_OTHER_CODE = 'F31-INST-OTRO';
|
||||||
|
|
||||||
|
function subOtherCode(parentCode: string): string {
|
||||||
|
return `F31-SUB-OTRO-${createHash('sha1').update(parentCode).digest('hex').slice(0, 12).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PhaseF31OtherInventoryFamilies1789844800000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF31OtherInventoryFamilies1789844800000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_families(
|
||||||
|
code,name,level,legacy_type_code,information_labels,source_reference,is_active
|
||||||
|
) VALUES ($1::varchar,'Otro / no catalogado','INSTALLATION',NULL,'[]'::jsonb,
|
||||||
|
'SYSTEM:F3.1:OTHER',true)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name=EXCLUDED.name,is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [INSTALLATION_OTHER_CODE]);
|
||||||
|
|
||||||
|
for (const parent of F31_INSTALLATION_FAMILIES) {
|
||||||
|
const code = subOtherCode(parent.code);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_families(
|
||||||
|
code,name,level,legacy_type_code,information_labels,source_reference,is_active
|
||||||
|
) VALUES ($1::varchar,'Otro / no catalogado','SUBINSTALLATION',NULL,'[]'::jsonb,
|
||||||
|
$2::varchar,true)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name=EXCLUDED.name,is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [code, `SYSTEM:F3.1:OTHER:${parent.code}`]);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
|
||||||
|
SELECT child.id,parent.id
|
||||||
|
FROM inventory_families child
|
||||||
|
JOIN inventory_families parent ON parent.code=$2::varchar
|
||||||
|
WHERE child.code=$1::varchar
|
||||||
|
ON CONFLICT (child_family_id) DO UPDATE
|
||||||
|
SET parent_family_id=EXCLUDED.parent_family_id
|
||||||
|
`, [code, parent.code]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si la Instalación misma es "Otro", también necesita una opción "Otro" hija.
|
||||||
|
const rootSubOther = subOtherCode(INSTALLATION_OTHER_CODE);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_families(
|
||||||
|
code,name,level,legacy_type_code,information_labels,source_reference,is_active
|
||||||
|
) VALUES ($1::varchar,'Otro / no catalogado','SUBINSTALLATION',NULL,'[]'::jsonb,
|
||||||
|
'SYSTEM:F3.1:OTHER:F31-INST-OTRO',true)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name=EXCLUDED.name,is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [rootSubOther]);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
|
||||||
|
SELECT child.id,parent.id
|
||||||
|
FROM inventory_families child
|
||||||
|
JOIN inventory_families parent ON parent.code=$2::varchar
|
||||||
|
WHERE child.code=$1::varchar
|
||||||
|
ON CONFLICT (child_family_id) DO UPDATE
|
||||||
|
SET parent_family_id=EXCLUDED.parent_family_id
|
||||||
|
`, [rootSubOther, INSTALLATION_OTHER_CODE]);
|
||||||
|
|
||||||
|
const otherCodes = [
|
||||||
|
INSTALLATION_OTHER_CODE,
|
||||||
|
...F31_INSTALLATION_FAMILIES.map((parent) => subOtherCode(parent.code)),
|
||||||
|
rootSubOther,
|
||||||
|
];
|
||||||
|
for (const familyCode of otherCodes) {
|
||||||
|
for (const title of F31_UNIVERSAL_FINDINGS) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
|
||||||
|
SELECT item.id,family.id
|
||||||
|
FROM finding_catalog_items item
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
CROSS JOIN inventory_families family
|
||||||
|
WHERE lower(category.code)='app26r2'
|
||||||
|
AND lower(btrim(item.title))=lower(btrim($2::text))
|
||||||
|
AND family.code=$1::varchar
|
||||||
|
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
|
||||||
|
`, [familyCode, title]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const codes = [
|
||||||
|
INSTALLATION_OTHER_CODE,
|
||||||
|
...F31_INSTALLATION_FAMILIES.map((parent) => subOtherCode(parent.code)),
|
||||||
|
subOtherCode(INSTALLATION_OTHER_CODE),
|
||||||
|
];
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM inventory_families
|
||||||
|
WHERE code=ANY($1::varchar[])
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM assets asset WHERE asset.inventory_family_id=inventory_families.id)
|
||||||
|
`, [codes]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF31FindingCatalogMerge1789844900000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF31FindingCatalogMerge1789844900000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE finding_catalog_item_merges (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
source_item_id uuid NOT NULL REFERENCES finding_catalog_items(id) ON DELETE RESTRICT,
|
||||||
|
canonical_item_id uuid NOT NULL REFERENCES finding_catalog_items(id) ON DELETE RESTRICT,
|
||||||
|
reason text NOT NULL,
|
||||||
|
merged_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
merged_by uuid NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
request_id varchar(128) NULL,
|
||||||
|
source_snapshot jsonb NOT NULL,
|
||||||
|
canonical_snapshot jsonb NOT NULL,
|
||||||
|
CONSTRAINT chk_finding_catalog_item_merges_distinct CHECK (source_item_id <> canonical_item_id),
|
||||||
|
CONSTRAINT uq_finding_catalog_item_merges_source UNIQUE (source_item_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_finding_catalog_item_merges_canonical ON finding_catalog_item_merges(canonical_item_id)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX idx_finding_catalog_item_merges_at ON finding_catalog_item_merges(merged_at)`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION protect_finding_catalog_item_merges_append_only()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'finding_catalog_item_merges es append-only; una fusión de catálogo no puede editarse ni borrarse';
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TRIGGER trg_finding_catalog_item_merges_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON finding_catalog_item_merges
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION protect_finding_catalog_item_merges_append_only()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_finding_catalog_item_merges_append_only ON finding_catalog_item_merges');
|
||||||
|
await queryRunner.query('DROP FUNCTION IF EXISTS protect_finding_catalog_item_merges_append_only()');
|
||||||
|
await queryRunner.query('DROP TABLE IF EXISTS finding_catalog_item_merges');
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class PhaseF31InspectorDocumentDelivery1789845000000 implements MigrationInterface {
|
||||||
|
name = 'PhaseF31InspectorDocumentDelivery1789845000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
ADD COLUMN recipient_user_id uuid NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
ADD CONSTRAINT fk_inspection_document_deliveries_recipient_user
|
||||||
|
FOREIGN KEY (recipient_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
DROP CONSTRAINT chk_inspection_document_deliveries_recipient_kind
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind
|
||||||
|
CHECK (recipient_kind IN ('COMPANY','OFFICE','DIRECTOR','INSPECTOR'))
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_inspection_document_deliveries_recipient_user
|
||||||
|
ON inspection_document_deliveries(recipient_user_id)
|
||||||
|
WHERE recipient_user_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM inspection_document_deliveries
|
||||||
|
WHERE recipient_kind='INSPECTOR'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_document_deliveries_recipient_user`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
DROP CONSTRAINT chk_inspection_document_deliveries_recipient_kind
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind
|
||||||
|
CHECK (recipient_kind IN ('COMPANY','OFFICE','DIRECTOR'))
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_inspection_document_deliveries_recipient_user
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
DROP COLUMN IF EXISTS recipient_user_id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,7 +308,7 @@ export class InspectionActsService {
|
|||||||
const visit = await this.lockVisit(manager, visitId);
|
const visit = await this.lockVisit(manager, visitId);
|
||||||
this.assertVisitOpen(visit);
|
this.assertVisitOpen(visit);
|
||||||
await this.assertActorAssigned(manager, visitId, principal);
|
await this.assertActorAssigned(manager, visitId, principal);
|
||||||
await this.assertVisitHasNoAct(manager, visitId);
|
await this.assertVisitHasNoDraftAct(manager, visitId);
|
||||||
await this.assertVisitAssets(manager, visitId, dto.assetIds);
|
await this.assertVisitAssets(manager, visitId, dto.assetIds);
|
||||||
const occurredAt = new Date(dto.occurredAt);
|
const occurredAt = new Date(dto.occurredAt);
|
||||||
const actYear = await this.yearAtProjectTimezone(manager, occurredAt);
|
const actYear = await this.yearAtProjectTimezone(manager, occurredAt);
|
||||||
@@ -632,20 +632,20 @@ export class InspectionActsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertVisitHasNoAct(
|
private async assertVisitHasNoDraftAct(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
visitId: string,
|
visitId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const rows = (await manager.query(`
|
const rows = (await manager.query(`
|
||||||
SELECT code
|
SELECT code
|
||||||
FROM inspection_acts
|
FROM inspection_acts
|
||||||
WHERE visit_id = $1
|
WHERE visit_id = $1 AND status = 'DRAFT'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, [visitId])) as Array<{ code: string }>;
|
`, [visitId])) as Array<{ code: string }>;
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'INSPECTION_VISIT_ACT_ALREADY_EXISTS',
|
code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS',
|
||||||
message: 'La visita ya tiene un acta; cada visita admite una única acta',
|
message: 'La inspección ya tiene un acta en borrador; preparala o cancelala antes de crear otra',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsEnum, IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator';
|
||||||
|
import { InspectionCompanySignatureManifestation } from '../../database/entities';
|
||||||
|
import { CreateInspectionSignatureDto } from './create-inspection-signature.dto';
|
||||||
|
|
||||||
|
export class CreateCompanySignatureDto extends CreateInspectionSignatureDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(InspectionCompanySignatureManifestation)
|
||||||
|
manifestation?: InspectionCompanySignatureManifestation;
|
||||||
|
|
||||||
|
@ValidateIf((dto: CreateCompanySignatureDto) =>
|
||||||
|
dto.manifestation === InspectionCompanySignatureManifestation.DISSENT,
|
||||||
|
)
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
@MaxLength(4000)
|
||||||
|
statement?: string;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c
|
|||||||
import { CloseInspectionActDto } from './dto/close-inspection-act.dto';
|
import { CloseInspectionActDto } from './dto/close-inspection-act.dto';
|
||||||
import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
|
import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
|
||||||
import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
|
import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
|
||||||
|
import { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
|
||||||
import { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto';
|
import { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto';
|
||||||
import { InspectionClosingService } from './inspection-closing.service';
|
import { InspectionClosingService } from './inspection-closing.service';
|
||||||
import {
|
import {
|
||||||
@@ -89,7 +90,7 @@ export class InspectionClosingController {
|
|||||||
}))
|
}))
|
||||||
companySignature(
|
companySignature(
|
||||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||||
@Body() dto: CreateInspectionSignatureDto,
|
@Body() dto: CreateCompanySignatureDto,
|
||||||
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
|
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
@Req() request: RequestWithContext,
|
@Req() request: RequestWithContext,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
InspectionActSignatureSource,
|
InspectionActSignatureSource,
|
||||||
InspectionActSignatureStatus,
|
InspectionActSignatureStatus,
|
||||||
InspectionActSignerType,
|
InspectionActSignerType,
|
||||||
|
InspectionCompanySignatureManifestation,
|
||||||
InspectionActStatus,
|
InspectionActStatus,
|
||||||
InspectionActUploadMode,
|
InspectionActUploadMode,
|
||||||
InspectionActVersionEvent,
|
InspectionActVersionEvent,
|
||||||
@@ -33,6 +34,7 @@ import type { CloseInspectionActDto } from './dto/close-inspection-act.dto';
|
|||||||
import { InspectionReportsService } from '../inspection-reports/inspection-reports.service';
|
import { InspectionReportsService } from '../inspection-reports/inspection-reports.service';
|
||||||
import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
|
import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
|
||||||
import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
|
import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
|
||||||
|
import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto';
|
||||||
import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto';
|
import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto';
|
||||||
import {
|
import {
|
||||||
inspectInspectionSignatureFile,
|
inspectInspectionSignatureFile,
|
||||||
@@ -70,6 +72,8 @@ interface SignatureView {
|
|||||||
position: string | null;
|
position: string | null;
|
||||||
status: InspectionActSignatureStatus;
|
status: InspectionActSignatureStatus;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
|
companyManifestation: InspectionCompanySignatureManifestation | null;
|
||||||
|
companyStatement: string | null;
|
||||||
mimeType: string | null;
|
mimeType: string | null;
|
||||||
sizeBytes: number | null;
|
sizeBytes: number | null;
|
||||||
imageSha256: string | null;
|
imageSha256: string | null;
|
||||||
@@ -429,7 +433,7 @@ export class InspectionClosingService {
|
|||||||
|
|
||||||
async signCompany(
|
async signCompany(
|
||||||
actId: string,
|
actId: string,
|
||||||
dto: CreateInspectionSignatureDto,
|
dto: CreateCompanySignatureDto,
|
||||||
file: UploadedInspectionSignatureFile | undefined,
|
file: UploadedInspectionSignatureFile | undefined,
|
||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
request: RequestWithContext,
|
request: RequestWithContext,
|
||||||
@@ -447,7 +451,7 @@ export class InspectionClosingService {
|
|||||||
assertMobileInspector(principal);
|
assertMobileInspector(principal);
|
||||||
return this.dataSource.transaction(async (manager) => {
|
return this.dataSource.transaction(async (manager) => {
|
||||||
const { act, visit } = await this.lockContext(manager, actId);
|
const { act, visit } = await this.lockContext(manager, actId);
|
||||||
this.assertReadyInProgress(act, visit);
|
this.assertReadyForCompanyOutcome(act, visit);
|
||||||
await this.assertActorAssigned(manager, visit.id, principal, true);
|
await this.assertActorAssigned(manager, visit.id, principal, true);
|
||||||
const responsible = await this.requireResponsible(manager, actId);
|
const responsible = await this.requireResponsible(manager, actId);
|
||||||
if (
|
if (
|
||||||
@@ -540,7 +544,8 @@ export class InspectionClosingService {
|
|||||||
assertMobileInspector(principal);
|
assertMobileInspector(principal);
|
||||||
const closed = await this.dataSource.transaction(async (manager) => {
|
const closed = await this.dataSource.transaction(async (manager) => {
|
||||||
const { act, visit } = await this.lockContext(manager, actId);
|
const { act, visit } = await this.lockContext(manager, actId);
|
||||||
this.assertReadyInProgress(act, visit);
|
this.assertReadyForFinalClosure(act, visit);
|
||||||
|
await this.assertActorAssigned(manager, visit.id, principal, true);
|
||||||
const closure = await this.requireClosure(manager, actId);
|
const closure = await this.requireClosure(manager, actId);
|
||||||
const signatures = await this.loadSignatures(manager, actId);
|
const signatures = await this.loadSignatures(manager, actId);
|
||||||
if (!signatures.some((item) => (
|
if (!signatures.some((item) => (
|
||||||
@@ -576,6 +581,8 @@ export class InspectionClosingService {
|
|||||||
position: item.position,
|
position: item.position,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
reason: item.reason,
|
reason: item.reason,
|
||||||
|
companyManifestation: item.companyManifestation,
|
||||||
|
companyStatement: item.companyStatement,
|
||||||
imageSha256: item.imageSha256,
|
imageSha256: item.imageSha256,
|
||||||
consentText: item.consentText,
|
consentText: item.consentText,
|
||||||
consentVersion: item.consentVersion,
|
consentVersion: item.consentVersion,
|
||||||
@@ -636,14 +643,6 @@ export class InspectionClosingService {
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING current_version AS "versionNumber"
|
RETURNING current_version AS "versionNumber"
|
||||||
`, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>;
|
`, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>;
|
||||||
await manager.query(`
|
|
||||||
UPDATE inspection_visits
|
|
||||||
SET status = 'CLOSED',
|
|
||||||
actual_closed_at = $2,
|
|
||||||
updated_by = $3,
|
|
||||||
updated_at = $2
|
|
||||||
WHERE id = $1
|
|
||||||
`, [visit.id, serverClosedAt, principal.userId]);
|
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
INSERT INTO inspection_act_versions (
|
INSERT INTO inspection_act_versions (
|
||||||
act_id, version_number, event, snapshot, actor_user_id, actor_username
|
act_id, version_number, event, snapshot, actor_user_id, actor_username
|
||||||
@@ -672,6 +671,7 @@ export class InspectionClosingService {
|
|||||||
visitId: visit.id,
|
visitId: visit.id,
|
||||||
versionNumber: Number(updated.versionNumber),
|
versionNumber: Number(updated.versionNumber),
|
||||||
findingsRemainOpen: true,
|
findingsRemainOpen: true,
|
||||||
|
visitRemainsIndependent: true,
|
||||||
},
|
},
|
||||||
}, manager);
|
}, manager);
|
||||||
await this.reports.ensureFrozenReport(manager, actId, principal, request);
|
await this.reports.ensureFrozenReport(manager, actId, principal, request);
|
||||||
@@ -710,7 +710,7 @@ export class InspectionClosingService {
|
|||||||
|
|
||||||
private async createSignedSignature(
|
private async createSignedSignature(
|
||||||
actId: string,
|
actId: string,
|
||||||
dto: CreateInspectionSignatureDto,
|
dto: CreateInspectionSignatureDto | CreateCompanySignatureDto,
|
||||||
file: UploadedInspectionSignatureFile | undefined,
|
file: UploadedInspectionSignatureFile | undefined,
|
||||||
fixedIdentity: null | {
|
fixedIdentity: null | {
|
||||||
signerType: InspectionActSignerType;
|
signerType: InspectionActSignerType;
|
||||||
@@ -737,7 +737,8 @@ export class InspectionClosingService {
|
|||||||
try {
|
try {
|
||||||
return await this.dataSource.transaction(async (manager) => {
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
const { act, visit } = await this.lockContext(manager, actId);
|
const { act, visit } = await this.lockContext(manager, actId);
|
||||||
this.assertReadyInProgress(act, visit);
|
if (fixedIdentity) this.assertReadyInProgress(act, visit);
|
||||||
|
else this.assertReadyForCompanyOutcome(act, visit);
|
||||||
await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment);
|
await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment);
|
||||||
const closure = await this.requireClosure(manager, actId);
|
const closure = await this.requireClosure(manager, actId);
|
||||||
const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId);
|
const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId);
|
||||||
@@ -778,6 +779,13 @@ export class InspectionClosingService {
|
|||||||
const source = this.signatureSource(principal);
|
const source = this.signatureSource(principal);
|
||||||
const signedAt = new Date();
|
const signedAt = new Date();
|
||||||
const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null;
|
const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null;
|
||||||
|
const companyDto = fixedIdentity ? null : dto as CreateCompanySignatureDto;
|
||||||
|
const companyManifestation = fixedIdentity
|
||||||
|
? null
|
||||||
|
: companyDto?.manifestation ?? InspectionCompanySignatureManifestation.CONFORMITY;
|
||||||
|
const companyStatement = companyManifestation === InspectionCompanySignatureManifestation.DISSENT
|
||||||
|
? companyDto?.statement?.trim() ?? null
|
||||||
|
: null;
|
||||||
const payload = {
|
const payload = {
|
||||||
actId,
|
actId,
|
||||||
preparedSha256: closure.preparedSha256,
|
preparedSha256: closure.preparedSha256,
|
||||||
@@ -788,6 +796,8 @@ export class InspectionClosingService {
|
|||||||
documentNumber: identity.documentNumber,
|
documentNumber: identity.documentNumber,
|
||||||
position: identity.position,
|
position: identity.position,
|
||||||
status: InspectionActSignatureStatus.SIGNED,
|
status: InspectionActSignatureStatus.SIGNED,
|
||||||
|
companyManifestation,
|
||||||
|
companyStatement,
|
||||||
imageSha256,
|
imageSha256,
|
||||||
consentText: identity.consentText,
|
consentText: identity.consentText,
|
||||||
consentVersion: CONSENT_VERSION,
|
consentVersion: CONSENT_VERSION,
|
||||||
@@ -806,6 +816,7 @@ export class InspectionClosingService {
|
|||||||
INSERT INTO inspection_act_signatures (
|
INSERT INTO inspection_act_signatures (
|
||||||
id, act_id, signer_type, signer_user_id, signer_name,
|
id, act_id, signer_type, signer_user_id, signer_name,
|
||||||
document_type, document_number, position, status,
|
document_type, document_number, position, status,
|
||||||
|
company_manifestation, company_statement,
|
||||||
original_name, stored_name, mime_type, size_bytes, image_sha256,
|
original_name, stored_name, mime_type, size_bytes, image_sha256,
|
||||||
consent_text, consent_version, consent_accepted_at,
|
consent_text, consent_version, consent_accepted_at,
|
||||||
client_signed_at, signed_at, latitude, longitude, accuracy_m,
|
client_signed_at, signed_at, latitude, longitude, accuracy_m,
|
||||||
@@ -814,11 +825,12 @@ export class InspectionClosingService {
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5,
|
$1, $2, $3, $4, $5,
|
||||||
$6, $7, $8, 'SIGNED',
|
$6, $7, $8, 'SIGNED',
|
||||||
$9, $10, $11, $12, $13,
|
$9, $10,
|
||||||
$14, $15, $16,
|
$11, $12, $13, $14, $15,
|
||||||
$17, $18, $19, $20, $21,
|
$16, $17, $18,
|
||||||
$22, $23, $24, $25,
|
$19, $20, $21, $22, $23,
|
||||||
$26, $18
|
$24, $25, $26, $27,
|
||||||
|
$28, $20
|
||||||
)
|
)
|
||||||
`, [
|
`, [
|
||||||
id,
|
id,
|
||||||
@@ -829,6 +841,8 @@ export class InspectionClosingService {
|
|||||||
identity.documentType,
|
identity.documentType,
|
||||||
identity.documentNumber,
|
identity.documentNumber,
|
||||||
identity.position,
|
identity.position,
|
||||||
|
companyManifestation,
|
||||||
|
companyStatement,
|
||||||
inspected.originalName,
|
inspected.originalName,
|
||||||
storedName,
|
storedName,
|
||||||
inspected.mimeType,
|
inspected.mimeType,
|
||||||
@@ -1266,6 +1280,8 @@ export class InspectionClosingService {
|
|||||||
signature.position,
|
signature.position,
|
||||||
signature.status,
|
signature.status,
|
||||||
signature.reason,
|
signature.reason,
|
||||||
|
signature.company_manifestation AS "companyManifestation",
|
||||||
|
signature.company_statement AS "companyStatement",
|
||||||
signature.original_name AS "originalName",
|
signature.original_name AS "originalName",
|
||||||
signature.stored_name AS "storedName",
|
signature.stored_name AS "storedName",
|
||||||
signature.mime_type AS "mimeType",
|
signature.mime_type AS "mimeType",
|
||||||
@@ -1365,6 +1381,26 @@ export class InspectionClosingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertReadyForCompanyOutcome(act: InspectionAct, visit: InspectionVisit): void {
|
||||||
|
if (act.status !== InspectionActStatus.READY
|
||||||
|
|| ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_ACT_NOT_READY_FOR_COMPANY',
|
||||||
|
message: 'El acta debe estar preparada; la firma de empresa puede completarse durante la inspección o después de su cierre',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertReadyForFinalClosure(act: InspectionAct, visit: InspectionVisit): void {
|
||||||
|
if (act.status !== InspectionActStatus.READY
|
||||||
|
|| ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_ACT_NOT_READY',
|
||||||
|
message: 'El acta debe estar preparada para completar su cierre definitivo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async assertActorAssigned(
|
private async assertActorAssigned(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
visitId: string,
|
visitId: string,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class MergeFindingCatalogItemDto {
|
||||||
|
@IsUUID('4')
|
||||||
|
canonicalItemId!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@MaxLength(2000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
|
||||||
|
import { FindingCatalogService } from './finding-catalog.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class F3FindingCatalogResolverService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly legacyCatalog: FindingCatalogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
|
||||||
|
const [asset] = await this.dataSource.query(`
|
||||||
|
SELECT asset.id,asset.code,asset.name,
|
||||||
|
asset.inventory_family_id AS "familyId",
|
||||||
|
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel"
|
||||||
|
FROM assets asset
|
||||||
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
|
WHERE asset.id=$1::uuid
|
||||||
|
`, [assetId]) as Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
familyId: string | null;
|
||||||
|
familyCode: string | null;
|
||||||
|
familyName: string | null;
|
||||||
|
familyLevel: string | null;
|
||||||
|
}>;
|
||||||
|
if (!asset) {
|
||||||
|
throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
||||||
|
}
|
||||||
|
if (!asset.familyId) return this.legacyCatalog.listApplicableForAsset(assetId, query);
|
||||||
|
|
||||||
|
const filters = [
|
||||||
|
'mapping.inventory_family_id=$1::uuid',
|
||||||
|
'item.is_active=true',
|
||||||
|
'category.is_active=true',
|
||||||
|
'merge_record.source_item_id IS NULL',
|
||||||
|
];
|
||||||
|
const params: unknown[] = [asset.familyId];
|
||||||
|
if (query.categoryId) {
|
||||||
|
params.push(query.categoryId);
|
||||||
|
filters.push(`category.id=$${params.length}::uuid`);
|
||||||
|
}
|
||||||
|
if (query.search?.trim()) {
|
||||||
|
params.push(`%${query.search.trim()}%`);
|
||||||
|
const token = `$${params.length}`;
|
||||||
|
filters.push(`(item.title ILIKE ${token} OR COALESCE(item.legal_basis,'') ILIKE ${token} OR COALESCE(item.glossary,'') ILIKE ${token})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = filters.join(' AND ');
|
||||||
|
const [categories, items] = await Promise.all([
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT DISTINCT category.id,category.code,category.name,category.sort_order AS "sortOrder"
|
||||||
|
FROM finding_catalog_item_inventory_families mapping
|
||||||
|
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
LEFT JOIN finding_catalog_item_merges merge_record ON merge_record.source_item_id=item.id
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY category.sort_order,category.name,category.id
|
||||||
|
`, params),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT item.id,item.category_id AS "categoryId",item.code,
|
||||||
|
item.source_number AS "sourceNumber",item.title,item.legal_basis AS "legalBasis",
|
||||||
|
item.glossary,item.suggested_severity AS "suggestedSeverity",item.revision,
|
||||||
|
category.name AS "categoryName"
|
||||||
|
FROM finding_catalog_item_inventory_families mapping
|
||||||
|
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
LEFT JOIN finding_catalog_item_merges merge_record ON merge_record.source_item_id=item.id
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY category.sort_order,item.source_number,item.code
|
||||||
|
`, params),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
asset: { id: asset.id, code: asset.code, name: asset.name },
|
||||||
|
inventoryFamily: {
|
||||||
|
id: asset.familyId,
|
||||||
|
code: asset.familyCode,
|
||||||
|
name: asset.familyName,
|
||||||
|
level: asset.familyLevel,
|
||||||
|
},
|
||||||
|
typeConfigured: true,
|
||||||
|
configurationReason: `F3.1 · Catálogo del Excel asociado a ${asset.familyName ?? 'la familia técnica'}`,
|
||||||
|
categories,
|
||||||
|
items,
|
||||||
|
other: {
|
||||||
|
enabled: true,
|
||||||
|
code: 'OTHER',
|
||||||
|
label: 'OTROS',
|
||||||
|
help: 'Usalo cuando el hallazgo no exista en la familia técnica. Se enviará una propuesta a revisión de oficina.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
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 { AuditAction } from '../database/entities';
|
||||||
|
import type { MergeFindingCatalogItemDto } from './dto/merge-finding-catalog-item.dto';
|
||||||
|
|
||||||
|
type CatalogItemRow = {
|
||||||
|
id: string;
|
||||||
|
categoryId: string;
|
||||||
|
categoryCode: string;
|
||||||
|
categoryName: string;
|
||||||
|
code: string;
|
||||||
|
sourceNumber: number;
|
||||||
|
title: string;
|
||||||
|
legalBasis: string | null;
|
||||||
|
glossary: string | null;
|
||||||
|
importNote: string | null;
|
||||||
|
suggestedSeverity: number | null;
|
||||||
|
revision: number;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FindingCatalogMergeService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async status(itemId: string) {
|
||||||
|
const requested = await this.load(this.dataSource.manager, itemId, false);
|
||||||
|
const canonicalId = await this.resolveCanonicalId(this.dataSource.manager, itemId);
|
||||||
|
const canonical = canonicalId === itemId
|
||||||
|
? requested
|
||||||
|
: await this.load(this.dataSource.manager, canonicalId, false);
|
||||||
|
const aliases = await this.dataSource.query(`
|
||||||
|
WITH RECURSIVE aliases AS (
|
||||||
|
SELECT source_item_id,canonical_item_id,reason,merged_at,1 AS depth
|
||||||
|
FROM finding_catalog_item_merges WHERE canonical_item_id=$1::uuid
|
||||||
|
UNION ALL
|
||||||
|
SELECT merge_record.source_item_id,merge_record.canonical_item_id,
|
||||||
|
merge_record.reason,merge_record.merged_at,aliases.depth+1
|
||||||
|
FROM finding_catalog_item_merges merge_record
|
||||||
|
JOIN aliases ON merge_record.canonical_item_id=aliases.source_item_id
|
||||||
|
WHERE aliases.depth<32
|
||||||
|
)
|
||||||
|
SELECT alias.source_item_id AS id,item.code,item.title,alias.reason,
|
||||||
|
alias.merged_at AS "mergedAt",alias.depth
|
||||||
|
FROM aliases alias
|
||||||
|
JOIN finding_catalog_items item ON item.id=alias.source_item_id
|
||||||
|
ORDER BY alias.merged_at,alias.depth
|
||||||
|
`, [canonicalId]);
|
||||||
|
return {
|
||||||
|
requested,
|
||||||
|
isMerged: canonicalId !== itemId,
|
||||||
|
canonical,
|
||||||
|
aliases,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async merge(
|
||||||
|
sourceItemId: string,
|
||||||
|
dto: MergeFindingCatalogItemDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const existing = await manager.query(`
|
||||||
|
SELECT canonical_item_id AS "canonicalItemId"
|
||||||
|
FROM finding_catalog_item_merges WHERE source_item_id=$1::uuid
|
||||||
|
`, [sourceItemId]) as Array<{ canonicalItemId: string }>;
|
||||||
|
if (existing[0]) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FINDING_CATALOG_ALREADY_MERGED',
|
||||||
|
message: 'Este Hallazgo de catálogo ya fue fusionado',
|
||||||
|
canonicalItemId: existing[0].canonicalItemId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalItemId = await this.resolveCanonicalId(manager, dto.canonicalItemId);
|
||||||
|
if (canonicalItemId === sourceItemId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FINDING_CATALOG_MERGE_CYCLE',
|
||||||
|
message: 'La fusión generaría un ciclo en el catálogo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [sourceItemId, canonicalItemId].sort();
|
||||||
|
const rows = await manager.query(`
|
||||||
|
SELECT item.id,item.category_id AS "categoryId",category.code AS "categoryCode",
|
||||||
|
category.name AS "categoryName",item.code,item.source_number AS "sourceNumber",
|
||||||
|
item.title,item.legal_basis AS "legalBasis",item.glossary,item.import_note AS "importNote",
|
||||||
|
item.suggested_severity AS "suggestedSeverity",item.revision,item.is_active AS "isActive"
|
||||||
|
FROM finding_catalog_items item
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
WHERE item.id=ANY($1::uuid[])
|
||||||
|
ORDER BY item.id
|
||||||
|
FOR UPDATE OF item
|
||||||
|
`, [ids]) as CatalogItemRow[];
|
||||||
|
const source = rows.find((item) => item.id === sourceItemId);
|
||||||
|
const canonical = rows.find((item) => item.id === canonicalItemId);
|
||||||
|
if (!source || !canonical) throw new NotFoundException({
|
||||||
|
code: 'FINDING_CATALOG_ITEM_NOT_FOUND',
|
||||||
|
message: 'Hallazgo de catálogo no encontrado',
|
||||||
|
});
|
||||||
|
if (!source.isActive) throw new ConflictException({
|
||||||
|
code: 'FINDING_CATALOG_MERGE_SOURCE_INACTIVE',
|
||||||
|
message: 'El Hallazgo de origen ya está inactivo',
|
||||||
|
});
|
||||||
|
if (!canonical.isActive) throw new ConflictException({
|
||||||
|
code: 'FINDING_CATALOG_MERGE_CANONICAL_INACTIVE',
|
||||||
|
message: 'El Hallazgo canónico debe estar activo',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceSnapshot = this.snapshot(source);
|
||||||
|
const canonicalSnapshot = this.snapshot(canonical);
|
||||||
|
|
||||||
|
// La aplicabilidad futura pasa al canónico; los Hallazgos emitidos nunca se reescriben.
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
|
||||||
|
SELECT $2::uuid,inventory_family_id
|
||||||
|
FROM finding_catalog_item_inventory_families
|
||||||
|
WHERE catalog_item_id=$1::uuid
|
||||||
|
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
|
||||||
|
`, [source.id, canonical.id]);
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO finding_catalog_item_asset_types(catalog_item_id,asset_type_id,created_by)
|
||||||
|
SELECT $2::uuid,asset_type_id,$3::uuid
|
||||||
|
FROM finding_catalog_item_asset_types
|
||||||
|
WHERE catalog_item_id=$1::uuid
|
||||||
|
ON CONFLICT (catalog_item_id,asset_type_id) DO NOTHING
|
||||||
|
`, [source.id, canonical.id, principal.userId]);
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO finding_catalog_asset_overrides(
|
||||||
|
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
|
||||||
|
)
|
||||||
|
SELECT asset_id,$2::uuid,is_enabled,
|
||||||
|
'F3.1 merge catálogo: ' || COALESCE(reason,''),$3::uuid,$3::uuid
|
||||||
|
FROM finding_catalog_asset_overrides source_override
|
||||||
|
WHERE source_override.catalog_item_id=$1::uuid
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM finding_catalog_asset_overrides target_override
|
||||||
|
WHERE target_override.asset_id=source_override.asset_id
|
||||||
|
AND target_override.catalog_item_id=$2::uuid
|
||||||
|
)
|
||||||
|
`, [source.id, canonical.id, principal.userId]);
|
||||||
|
|
||||||
|
const [mergeRecord] = await manager.query(`
|
||||||
|
INSERT INTO finding_catalog_item_merges(
|
||||||
|
source_item_id,canonical_item_id,reason,merged_by,request_id,
|
||||||
|
source_snapshot,canonical_snapshot
|
||||||
|
) VALUES ($1::uuid,$2::uuid,$3::text,$4::uuid,$5::varchar,$6::jsonb,$7::jsonb)
|
||||||
|
RETURNING id,source_item_id AS "sourceItemId",canonical_item_id AS "canonicalItemId",
|
||||||
|
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",request_id AS "requestId"
|
||||||
|
`, [
|
||||||
|
source.id,
|
||||||
|
canonical.id,
|
||||||
|
dto.reason,
|
||||||
|
principal.userId,
|
||||||
|
request.requestId,
|
||||||
|
sourceSnapshot,
|
||||||
|
canonicalSnapshot,
|
||||||
|
]) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE finding_catalog_items
|
||||||
|
SET is_active=false,revision=revision+1,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1::uuid
|
||||||
|
`, [source.id]);
|
||||||
|
const updatedSource = await this.load(manager, source.id, false);
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO finding_catalog_item_versions(
|
||||||
|
item_id,revision,snapshot,actor_user_id,actor_username
|
||||||
|
) VALUES ($1::uuid,$2::integer,$3::jsonb,$4::uuid,$5::varchar)
|
||||||
|
`, [
|
||||||
|
updatedSource.id,
|
||||||
|
updatedSource.revision,
|
||||||
|
this.snapshot(updatedSource),
|
||||||
|
principal.userId,
|
||||||
|
principal.username,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.FINDING_CATALOG_ITEM_UPDATED,
|
||||||
|
entityType: 'finding_catalog_item_merge',
|
||||||
|
entityId: String(mergeRecord.id),
|
||||||
|
beforeData: sourceSnapshot,
|
||||||
|
afterData: canonicalSnapshot,
|
||||||
|
metadata: {
|
||||||
|
operation: 'CHRONOLOGICAL_CATALOG_MERGE',
|
||||||
|
sourceItemId: source.id,
|
||||||
|
canonicalItemId: canonical.id,
|
||||||
|
reason: dto.reason,
|
||||||
|
historicalFindingsRewritten: false,
|
||||||
|
historicalProposalsRewritten: false,
|
||||||
|
futureApplicabilityMovedToCanonical: true,
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
|
||||||
|
return {
|
||||||
|
merge: mergeRecord,
|
||||||
|
source: updatedSource,
|
||||||
|
canonical,
|
||||||
|
historyPolicy: 'EMITTED_FINDINGS_PRESERVED',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCanonicalId(manager: EntityManager, itemId: string): Promise<string> {
|
||||||
|
const rows = await manager.query(`
|
||||||
|
WITH RECURSIVE chain AS (
|
||||||
|
SELECT source_item_id,canonical_item_id,1 AS depth
|
||||||
|
FROM finding_catalog_item_merges WHERE source_item_id=$1::uuid
|
||||||
|
UNION ALL
|
||||||
|
SELECT merge_record.source_item_id,merge_record.canonical_item_id,chain.depth+1
|
||||||
|
FROM finding_catalog_item_merges merge_record
|
||||||
|
JOIN chain ON merge_record.source_item_id=chain.canonical_item_id
|
||||||
|
WHERE chain.depth<32
|
||||||
|
)
|
||||||
|
SELECT canonical_item_id AS id FROM chain ORDER BY depth DESC LIMIT 1
|
||||||
|
`, [itemId]) as Array<{ id: string }>;
|
||||||
|
return rows[0]?.id ?? itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(manager: EntityManager, itemId: string, lock: boolean): Promise<CatalogItemRow> {
|
||||||
|
const lockSql = lock ? 'FOR UPDATE OF item' : '';
|
||||||
|
const rows = await manager.query(`
|
||||||
|
SELECT item.id,item.category_id AS "categoryId",category.code AS "categoryCode",
|
||||||
|
category.name AS "categoryName",item.code,item.source_number AS "sourceNumber",
|
||||||
|
item.title,item.legal_basis AS "legalBasis",item.glossary,item.import_note AS "importNote",
|
||||||
|
item.suggested_severity AS "suggestedSeverity",item.revision,item.is_active AS "isActive"
|
||||||
|
FROM finding_catalog_items item
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
WHERE item.id=$1::uuid ${lockSql}
|
||||||
|
`, [itemId]) as CatalogItemRow[];
|
||||||
|
if (!rows[0]) throw new NotFoundException({
|
||||||
|
code: 'FINDING_CATALOG_ITEM_NOT_FOUND',
|
||||||
|
message: 'Hallazgo de catálogo no encontrado',
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private snapshot(item: CatalogItemRow): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
categoryId: item.categoryId,
|
||||||
|
categoryCode: item.categoryCode,
|
||||||
|
categoryName: item.categoryName,
|
||||||
|
code: item.code,
|
||||||
|
sourceNumber: item.sourceNumber,
|
||||||
|
title: item.title,
|
||||||
|
legalBasis: item.legalBasis,
|
||||||
|
glossary: item.glossary,
|
||||||
|
importNote: item.importNote,
|
||||||
|
suggestedSeverity: item.suggestedSeverity,
|
||||||
|
revision: item.revision,
|
||||||
|
isActive: item.isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,15 +6,22 @@ import { CreateFindingCatalogItemDto } from './dto/create-finding-catalog-item.d
|
|||||||
import { CreateFindingCategoryDto } from './dto/create-finding-category.dto';
|
import { CreateFindingCategoryDto } from './dto/create-finding-category.dto';
|
||||||
import { ListFindingCatalogProposalsQueryDto } from './dto/list-finding-catalog-proposals-query.dto';
|
import { ListFindingCatalogProposalsQueryDto } from './dto/list-finding-catalog-proposals-query.dto';
|
||||||
import { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
|
import { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
|
||||||
|
import { MergeFindingCatalogItemDto } from './dto/merge-finding-catalog-item.dto';
|
||||||
import { ReplaceFindingCatalogSelectionDto } from './dto/replace-finding-catalog-selection.dto';
|
import { ReplaceFindingCatalogSelectionDto } from './dto/replace-finding-catalog-selection.dto';
|
||||||
import { ReviewFindingCatalogProposalDto } from './dto/review-finding-catalog-proposal.dto';
|
import { ReviewFindingCatalogProposalDto } from './dto/review-finding-catalog-proposal.dto';
|
||||||
import { UpdateFindingCatalogItemDto } from './dto/update-finding-catalog-item.dto';
|
import { UpdateFindingCatalogItemDto } from './dto/update-finding-catalog-item.dto';
|
||||||
import { UpdateFindingCategoryDto } from './dto/update-finding-category.dto';
|
import { UpdateFindingCategoryDto } from './dto/update-finding-category.dto';
|
||||||
|
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
|
||||||
|
import { FindingCatalogMergeService } from './finding-catalog-merge.service';
|
||||||
import { FindingCatalogService } from './finding-catalog.service';
|
import { FindingCatalogService } from './finding-catalog.service';
|
||||||
|
|
||||||
@Controller('finding-catalog')
|
@Controller('finding-catalog')
|
||||||
export class FindingCatalogController {
|
export class FindingCatalogController {
|
||||||
constructor(private readonly catalog: FindingCatalogService) {}
|
constructor(
|
||||||
|
private readonly catalog: FindingCatalogService,
|
||||||
|
private readonly applicableCatalog: F3FindingCatalogResolverService,
|
||||||
|
private readonly catalogMerges: FindingCatalogMergeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions('finding_catalog.read')
|
@RequirePermissions('finding_catalog.read')
|
||||||
@@ -27,8 +34,8 @@ export class FindingCatalogController {
|
|||||||
applicable(
|
applicable(
|
||||||
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
@Query() query: ListFindingCatalogQueryDto,
|
@Query() query: ListFindingCatalogQueryDto,
|
||||||
) {
|
): Promise<unknown> {
|
||||||
return this.catalog.listApplicableForAsset(assetId, query);
|
return this.applicableCatalog.listApplicableForAsset(assetId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('admin')
|
@Get('admin')
|
||||||
@@ -123,6 +130,25 @@ export class FindingCatalogController {
|
|||||||
return this.catalog.createItem(dto, principal, request);
|
return this.catalog.createItem(dto, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('items/:id/merge-status')
|
||||||
|
@RequirePermissions('finding_catalog.manage')
|
||||||
|
mergeStatus(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.catalogMerges.status(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('items/:id/merge')
|
||||||
|
@RequirePermissions('finding_catalog.manage')
|
||||||
|
mergeItem(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: MergeFindingCatalogItemDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.catalogMerges.merge(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('items/:id')
|
@Patch('items/:id')
|
||||||
@RequirePermissions('finding_catalog.manage')
|
@RequirePermissions('finding_catalog.manage')
|
||||||
updateItem(
|
updateItem(
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { FindingCatalogController } from './finding-catalog.controller';
|
import { FindingCatalogController } from './finding-catalog.controller';
|
||||||
|
import { FindingCatalogMergeService } from './finding-catalog-merge.service';
|
||||||
import { FindingCatalogService } from './finding-catalog.service';
|
import { FindingCatalogService } from './finding-catalog.service';
|
||||||
|
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
|
||||||
import {
|
import {
|
||||||
InspectionActFindingsController,
|
InspectionActFindingsController,
|
||||||
InspectionFindingsController,
|
InspectionFindingsController,
|
||||||
@@ -26,8 +28,16 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
FindingCatalogService,
|
FindingCatalogService,
|
||||||
|
F3FindingCatalogResolverService,
|
||||||
|
FindingCatalogMergeService,
|
||||||
InspectionFindingsService,
|
InspectionFindingsService,
|
||||||
InspectionEvidenceService,
|
InspectionEvidenceService,
|
||||||
],
|
],
|
||||||
|
exports: [
|
||||||
|
FindingCatalogService,
|
||||||
|
F3FindingCatalogResolverService,
|
||||||
|
FindingCatalogMergeService,
|
||||||
|
InspectionFindingsService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class InspectionFindingsModule {}
|
export class InspectionFindingsModule {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ConflictException, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { EntityManager } from 'typeorm';
|
import type { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
export interface FieldDiscoveryInspectionLinkResult {
|
export interface FieldDiscoveryInspectionLinkResult {
|
||||||
@@ -18,7 +18,7 @@ export class FieldDiscoveryInspectionLinkService {
|
|||||||
SELECT id, status
|
SELECT id, status
|
||||||
FROM inspection_acts
|
FROM inspection_acts
|
||||||
WHERE visit_id = $1
|
WHERE visit_id = $1
|
||||||
AND status <> 'CANCELLED'
|
AND status = 'DRAFT'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
@@ -26,25 +26,60 @@ export class FieldDiscoveryInspectionLinkService {
|
|||||||
[visitId],
|
[visitId],
|
||||||
)) as Array<{ id: string; status: string }>;
|
)) as Array<{ id: string; status: string }>;
|
||||||
|
|
||||||
if (act && act.status !== 'DRAFT') {
|
const [existing] = (await manager.query(
|
||||||
throw new ConflictException({
|
`
|
||||||
code: 'FIELD_DISCOVERY_ACT_NOT_EDITABLE',
|
SELECT included, planning_source AS "planningSource"
|
||||||
message: 'El Acta ya no admite nuevos elementos ni Hallazgos',
|
FROM inspection_visit_assets
|
||||||
});
|
WHERE visit_id = $1 AND asset_id = $2
|
||||||
}
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[visitId, assetId],
|
||||||
|
)) as Array<{ included: boolean; planningSource: string }>;
|
||||||
|
|
||||||
await manager.query(
|
await manager.query(
|
||||||
`
|
`
|
||||||
INSERT INTO inspection_visit_assets (visit_id, asset_id, included, added_by)
|
INSERT INTO inspection_visit_assets (
|
||||||
VALUES ($1, $2, true, $3)
|
visit_id, asset_id, included, planning_source, added_by,
|
||||||
|
exclusion_reason, excluded_by, excluded_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, true, 'FIELD', $3, NULL, NULL, NULL)
|
||||||
ON CONFLICT (visit_id, asset_id) DO UPDATE SET
|
ON CONFLICT (visit_id, asset_id) DO UPDATE SET
|
||||||
included = true,
|
included = true,
|
||||||
|
planning_source = CASE
|
||||||
|
WHEN inspection_visit_assets.planning_source IN ('AUTOMATIC','VERIFICATION')
|
||||||
|
THEN inspection_visit_assets.planning_source
|
||||||
|
ELSE 'FIELD'
|
||||||
|
END,
|
||||||
added_by = EXCLUDED.added_by,
|
added_by = EXCLUDED.added_by,
|
||||||
|
exclusion_reason = NULL,
|
||||||
|
excluded_by = NULL,
|
||||||
|
excluded_at = NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
`,
|
`,
|
||||||
[visitId, assetId, userId],
|
[visitId, assetId, userId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const eventType = existing && existing.included ? null : 'FIELD_INCLUDED';
|
||||||
|
if (eventType) {
|
||||||
|
await manager.query(
|
||||||
|
`
|
||||||
|
INSERT INTO inspection_visit_asset_events (
|
||||||
|
visit_id, asset_id, event_type, actor_user_id, metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, 'FIELD_INCLUDED', $3, $4::jsonb)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
visitId,
|
||||||
|
assetId,
|
||||||
|
userId,
|
||||||
|
JSON.stringify({
|
||||||
|
fieldSelection: true,
|
||||||
|
previousPlanningSource: existing?.planningSource ?? null,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (act) {
|
if (act) {
|
||||||
await manager.query(
|
await manager.query(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ function lines(snapshot: Record<string, unknown>): string[] {
|
|||||||
const team=asArray(prepared.team);
|
const team=asArray(prepared.team);
|
||||||
const assets=asArray(prepared.assets);
|
const assets=asArray(prepared.assets);
|
||||||
const findings=asArray(prepared.findings);
|
const findings=asArray(prepared.findings);
|
||||||
|
const signatures=asArray(snapshot.signatures);
|
||||||
|
const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE');
|
||||||
const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))];
|
const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))];
|
||||||
const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))];
|
const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))];
|
||||||
const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean);
|
const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean);
|
||||||
@@ -42,6 +44,15 @@ function lines(snapshot: Record<string, unknown>): string[] {
|
|||||||
out.push('', 'HALLAZGOS');
|
out.push('', 'HALLAZGOS');
|
||||||
if(!findings.length) out.push('Sin hallazgos registrados.');
|
if(!findings.length) out.push('Sin hallazgos registrados.');
|
||||||
for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); }
|
for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); }
|
||||||
|
out.push('', 'CONSTANCIA DE LA EMPRESA');
|
||||||
|
if(!companySignature) out.push('Firma o constancia pendiente.');
|
||||||
|
else if(text(companySignature.status,'')==='SIGNED'){
|
||||||
|
const manifestation=text(companySignature.companyManifestation,'CONFORMITY');
|
||||||
|
out.push(manifestation==='DISSENT'?'Firma en disidencia':'Firma en conformidad');
|
||||||
|
if(manifestation==='DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
|
||||||
|
} else {
|
||||||
|
out.push(...wrap(`${text(companySignature.status)}: ${text(companySignature.reason)}`));
|
||||||
|
}
|
||||||
out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`);
|
out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,470 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } 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 { AuditAction, AuditSource } from '../database/entities';
|
||||||
|
import type { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
|
||||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
|
||||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
|
||||||
import { AuditAction, AuditSource } from '../database/entities';
|
|
||||||
import type { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
|
|
||||||
|
|
||||||
export interface DeliveryRow { id:string; actId:string; reportId:string|null; documentKind:'ACT_PDF'|'REPORT_WORD'; recipientKind:'COMPANY'|'OFFICE'|'DIRECTOR'; recipientAssetId:string|null; recipientEmail:string|null; status:string; attempts:number; actCode:string; reportCode:string|null; }
|
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR';
|
||||||
|
|
||||||
|
export interface DeliveryRow {
|
||||||
|
id: string;
|
||||||
|
actId: string;
|
||||||
|
reportId: string | null;
|
||||||
|
documentKind: 'ACT_PDF' | 'REPORT_WORD';
|
||||||
|
recipientKind: DeliveryRecipientKind;
|
||||||
|
recipientAssetId: string | null;
|
||||||
|
recipientUserId: string | null;
|
||||||
|
recipientEmail: string | null;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
actCode: string;
|
||||||
|
reportCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InspectionDocumentDeliveryService {
|
export class InspectionDocumentDeliveryService {
|
||||||
constructor(private readonly dataSource:DataSource, private readonly pdf:InspectionActPdfService, private readonly word:InspectionReportWordService, private readonly smtp:SmtpDeliveryService, private readonly audit:AuditService, private readonly config:ConfigService){}
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly pdf: InspectionActPdfService,
|
||||||
|
private readonly word: InspectionReportWordService,
|
||||||
|
private readonly smtp: SmtpDeliveryService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async settings(){ const [row]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail",updated_at AS "updatedAt" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null;updatedAt:Date}>; return {...row,smtpConfigured:this.smtp.configured(),mailFrom:this.config.get<string>('MAIL_FROM')??null}; }
|
async settings() {
|
||||||
async updateSettings(dto:UpdateDocumentDeliverySettingsDto,principal:AuthPrincipal,request:RequestWithContext){ const before=await this.settings(); await this.dataSource.query(`UPDATE institutional_delivery_settings SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP WHERE id=1`,[dto.officeEmail??null,dto.directorEmail??null,principal.userId]); const after=await this.settings(); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.DOCUMENT_DELIVERY_SETTINGS_UPDATED,entityType:'institutional_delivery_settings',entityId:'1',beforeData:before,afterData:after}); return after; }
|
const [row] = await this.dataSource.query(`
|
||||||
async list(){ const data=await this.dataSource.query(`SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",d.recipient_asset_id AS "recipientAssetId",d.recipient_email AS "recipientEmail",d.status,d.attempts,d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt",d.provider_message_id AS "providerMessageId",d.last_error AS "lastError",d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode",recipient.name AS "recipientAssetName" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id ORDER BY d.created_at DESC LIMIT 200`); return {data}; }
|
SELECT
|
||||||
|
office_email AS "officeEmail",
|
||||||
async dispatchForAct(actId:string):Promise<void>{ await this.pdf.ensure(actId).catch(()=>undefined); const [report]=await this.dataSource.query(`SELECT id FROM inspection_reports WHERE act_id=$1`,[actId]) as Array<{id:string}>; if(report)await this.word.ensure(report.id); await this.ensureRows(actId,report?.id??null); const rows=await this.rowsForAct(actId); for(const row of rows)await this.attempt(row).catch(()=>undefined); }
|
director_email AS "directorEmail",
|
||||||
async retry(id:string,principal:AuthPrincipal,request:RequestWithContext):Promise<DeliveryRow>{ const row=await this.load(id); await this.audit.record({...administrationAuditContext(principal,request),action:AuditAction.DOCUMENT_DELIVERY_RETRY_REQUESTED,entityType:'inspection_document_delivery',entityId:id,metadata:{actId:row.actId,documentKind:row.documentKind,recipientKind:row.recipientKind}}); await this.refreshRecipient(row); await this.attempt(await this.load(id)); return this.load(id); }
|
updated_at AS "updatedAt"
|
||||||
async retryPending(principal:AuthPrincipal,request:RequestWithContext){ const rows=await this.dataSource.query(`SELECT id FROM inspection_document_deliveries WHERE status<>'SENT' ORDER BY created_at ASC LIMIT 100`) as Array<{id:string}>; for(const item of rows)await this.retry(item.id,principal,request).catch(()=>undefined); return {processed:rows.length}; }
|
FROM institutional_delivery_settings
|
||||||
|
WHERE id=1
|
||||||
private async ensureRows(actId:string,reportId:string|null){ const [settings]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null}>; const companies=await this.dataSource.query(`SELECT DISTINCT company.id,profile.notification_email AS email FROM inspection_act_assets link JOIN assets asset ON asset.id=link.asset_id JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id JOIN assets company ON company.id=COALESCE(asset.operator_company_id,CASE WHEN asset_type.operational_role='COMPANY' THEN asset.id END) LEFT JOIN organization_profiles profile ON profile.asset_id=company.id WHERE link.act_id=$1 AND link.included=true`,[actId]) as Array<{id:string;email:string|null}>;
|
`) as Array<{
|
||||||
for(const company of companies)await this.upsertRow(actId,reportId,'ACT_PDF','COMPANY',company.id,company.email);
|
officeEmail: string | null;
|
||||||
await this.upsertRow(actId,reportId,'ACT_PDF','OFFICE',null,settings?.officeEmail??null);
|
directorEmail: string | null;
|
||||||
if(reportId)await this.upsertRow(actId,reportId,'REPORT_WORD','DIRECTOR',null,settings?.directorEmail??null);
|
updatedAt: Date;
|
||||||
|
}>;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
smtpConfigured: this.smtp.configured(),
|
||||||
|
mailFrom: this.config.get<string>('MAIL_FROM') ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
private async upsertRow(actId:string,reportId:string|null,documentKind:string,recipientKind:string,recipientAssetId:string|null,recipientEmail:string|null){ await this.dataSource.query(`INSERT INTO inspection_document_deliveries (act_id,report_id,document_kind,recipient_kind,recipient_asset_id,recipient_key,recipient_email,status) VALUES ($1,$2,$3,$4,$5,COALESCE($5::uuid,'00000000-0000-0000-0000-000000000000'::uuid),$6,$7) ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id),recipient_email=COALESCE(inspection_document_deliveries.recipient_email,EXCLUDED.recipient_email),updated_at=CURRENT_TIMESTAMP`,[actId,reportId,documentKind,recipientKind,recipientAssetId,recipientEmail,recipientEmail?'PENDING':'WAITING_RECIPIENT']); }
|
|
||||||
private async rowsForAct(actId:string){ return this.dataSource.query(`SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",d.recipient_asset_id AS "recipientAssetId",d.recipient_email AS "recipientEmail",d.status,d.attempts,a.code AS "actCode",r.code AS "reportCode" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id WHERE d.act_id=$1 ORDER BY d.created_at`,[actId]) as Promise<DeliveryRow[]>; }
|
async updateSettings(
|
||||||
private async load(id:string){ const [row]=await this.dataSource.query(`SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",d.recipient_asset_id AS "recipientAssetId",d.recipient_email AS "recipientEmail",d.status,d.attempts,a.code AS "actCode",r.code AS "reportCode" FROM inspection_document_deliveries d JOIN inspection_acts a ON a.id=d.act_id LEFT JOIN inspection_reports r ON r.id=d.report_id WHERE d.id=$1`,[id]) as DeliveryRow[]; if(!row)throw new NotFoundException({code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada'}); return row; }
|
dto: UpdateDocumentDeliverySettingsDto,
|
||||||
private async refreshRecipient(row:DeliveryRow){ let email:string|null=null; if(row.recipientKind==='COMPANY'&&row.recipientAssetId){ const [company]=await this.dataSource.query(`SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1`,[row.recipientAssetId]) as Array<{email:string|null}>; email=company?.email??null; } else { const [settings]=await this.dataSource.query(`SELECT office_email AS "officeEmail",director_email AS "directorEmail" FROM institutional_delivery_settings WHERE id=1`) as Array<{officeEmail:string|null;directorEmail:string|null}>; email=row.recipientKind==='OFFICE'?settings?.officeEmail??null:settings?.directorEmail??null; } await this.dataSource.query(`UPDATE inspection_document_deliveries SET recipient_email=$2,status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1 AND status<>'SENT'`,[row.id,email]); }
|
principal: AuthPrincipal,
|
||||||
private async attempt(row:DeliveryRow){ if(row.status==='SENT')return; if(!row.recipientEmail){await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');return;} if(!this.smtp.configured()){await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');return;} let attachment:{filename:string;mimeType:string;content:Buffer}; try{ if(row.documentKind==='ACT_PDF'){await this.pdf.ensure(row.actId); const file=await this.pdf.content(row.actId); attachment={filename:file.originalName,mimeType:file.mimeType,content:file.buffer};}else{if(!row.reportId)throw new Error('Informe no vinculado'); await this.word.ensure(row.reportId); const file=await this.word.content(row.reportId); const {readFile}=await import('node:fs/promises'); attachment={filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath)};}}catch(error){await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error?error.message:'Documento no disponible');return;}
|
request: RequestWithContext,
|
||||||
await this.dataSource.query(`UPDATE inspection_document_deliveries SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[row.id]); try{ const label=row.documentKind==='ACT_PDF'?`Acta ${row.actCode}`:`Informe ${row.reportCode??''}`; const sent=await this.smtp.send({to:row.recipientEmail,subject:`DH Inspección · ${label}`,text:row.documentKind==='ACT_PDF'?`Se adjunta el acta cerrada e inmutable ${row.actCode}.`:`Se adjunta el informe Word automático ${row.reportCode??''} para revisión del Director de Hidrocarburos.`,attachment}); await this.dataSource.query(`UPDATE inspection_document_deliveries SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[row.id,sent.messageId]); await this.audit.record({action:AuditAction.DOCUMENT_DELIVERY_SENT,entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM,actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null,afterData:{recipientKind:row.recipientKind,documentKind:row.documentKind,status:'SENT'},metadata:{actId:row.actId,reportId:row.reportId}}); }catch(error){await this.setStatus(row.id,'FAILED',error instanceof Error?error.message:'Error de entrega');}
|
) {
|
||||||
|
const before = await this.settings();
|
||||||
|
await this.dataSource.query(`
|
||||||
|
UPDATE institutional_delivery_settings
|
||||||
|
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=1
|
||||||
|
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]);
|
||||||
|
const after = await this.settings();
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.DOCUMENT_DELIVERY_SETTINGS_UPDATED,
|
||||||
|
entityType: 'institutional_delivery_settings',
|
||||||
|
entityId: '1',
|
||||||
|
beforeData: before,
|
||||||
|
afterData: after,
|
||||||
|
});
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list() {
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
d.id,
|
||||||
|
d.act_id AS "actId",
|
||||||
|
d.report_id AS "reportId",
|
||||||
|
d.document_kind AS "documentKind",
|
||||||
|
d.recipient_kind AS "recipientKind",
|
||||||
|
d.recipient_asset_id AS "recipientAssetId",
|
||||||
|
d.recipient_user_id AS "recipientUserId",
|
||||||
|
d.recipient_email AS "recipientEmail",
|
||||||
|
d.status,
|
||||||
|
d.attempts,
|
||||||
|
d.last_attempt_at AS "lastAttemptAt",
|
||||||
|
d.sent_at AS "sentAt",
|
||||||
|
d.provider_message_id AS "providerMessageId",
|
||||||
|
d.last_error AS "lastError",
|
||||||
|
d.created_at AS "createdAt",
|
||||||
|
a.code AS "actCode",
|
||||||
|
r.code AS "reportCode",
|
||||||
|
recipient.name AS "recipientAssetName",
|
||||||
|
CASE
|
||||||
|
WHEN recipient_user.id IS NULL THEN NULL
|
||||||
|
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
|
||||||
|
END AS "recipientUserName"
|
||||||
|
FROM inspection_document_deliveries d
|
||||||
|
JOIN inspection_acts a ON a.id=d.act_id
|
||||||
|
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||||
|
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id
|
||||||
|
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
`);
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatchForAct(actId: string): Promise<void> {
|
||||||
|
await this.pdf.ensure(actId).catch(() => undefined);
|
||||||
|
const [report] = await this.dataSource.query(
|
||||||
|
`SELECT id FROM inspection_reports WHERE act_id=$1`,
|
||||||
|
[actId],
|
||||||
|
) as Array<{ id: string }>;
|
||||||
|
if (report) await this.word.ensure(report.id);
|
||||||
|
await this.ensureRows(actId, report?.id ?? null);
|
||||||
|
const rows = await this.rowsForAct(actId);
|
||||||
|
for (const row of rows) await this.attempt(row).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async retry(
|
||||||
|
id: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<DeliveryRow> {
|
||||||
|
const row = await this.load(id);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.DOCUMENT_DELIVERY_RETRY_REQUESTED,
|
||||||
|
entityType: 'inspection_document_delivery',
|
||||||
|
entityId: id,
|
||||||
|
metadata: {
|
||||||
|
actId: row.actId,
|
||||||
|
documentKind: row.documentKind,
|
||||||
|
recipientKind: row.recipientKind,
|
||||||
|
recipientUserId: row.recipientUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.refreshRecipient(row);
|
||||||
|
await this.attempt(await this.load(id));
|
||||||
|
return this.load(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryPending(principal: AuthPrincipal, request: RequestWithContext) {
|
||||||
|
const rows = await this.dataSource.query(`
|
||||||
|
SELECT id
|
||||||
|
FROM inspection_document_deliveries
|
||||||
|
WHERE status<>'SENT'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 100
|
||||||
|
`) as Array<{ id: string }>;
|
||||||
|
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined);
|
||||||
|
return { processed: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureRows(actId: string, reportId: string | null) {
|
||||||
|
const [settings] = await this.dataSource.query(`
|
||||||
|
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
||||||
|
FROM institutional_delivery_settings
|
||||||
|
WHERE id=1
|
||||||
|
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
||||||
|
|
||||||
|
const companies = await this.dataSource.query(`
|
||||||
|
SELECT DISTINCT company.id,profile.notification_email AS email
|
||||||
|
FROM inspection_act_assets link
|
||||||
|
JOIN assets asset ON asset.id=link.asset_id
|
||||||
|
JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
|
||||||
|
JOIN assets company ON company.id=COALESCE(
|
||||||
|
asset.operator_company_id,
|
||||||
|
CASE WHEN asset_type.operational_role='COMPANY' THEN asset.id END
|
||||||
|
)
|
||||||
|
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||||
|
WHERE link.act_id=$1 AND link.included=true
|
||||||
|
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||||
|
|
||||||
|
const [inspector] = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
inspector.id,
|
||||||
|
inspector.email
|
||||||
|
FROM inspection_acts act
|
||||||
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
|
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
|
||||||
|
WHERE act.id=$1
|
||||||
|
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||||
|
|
||||||
|
for (const company of companies) {
|
||||||
|
await this.upsertRow({
|
||||||
|
actId,
|
||||||
|
reportId,
|
||||||
|
documentKind: 'ACT_PDF',
|
||||||
|
recipientKind: 'COMPANY',
|
||||||
|
recipientAssetId: company.id,
|
||||||
|
recipientUserId: null,
|
||||||
|
recipientKey: company.id,
|
||||||
|
recipientEmail: company.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertRow({
|
||||||
|
actId,
|
||||||
|
reportId,
|
||||||
|
documentKind: 'ACT_PDF',
|
||||||
|
recipientKind: 'OFFICE',
|
||||||
|
recipientAssetId: null,
|
||||||
|
recipientUserId: null,
|
||||||
|
recipientKey: '00000000-0000-0000-0000-000000000000',
|
||||||
|
recipientEmail: settings?.officeEmail ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inspector) {
|
||||||
|
await this.upsertRow({
|
||||||
|
actId,
|
||||||
|
reportId,
|
||||||
|
documentKind: 'ACT_PDF',
|
||||||
|
recipientKind: 'INSPECTOR',
|
||||||
|
recipientAssetId: null,
|
||||||
|
recipientUserId: inspector.id,
|
||||||
|
recipientKey: inspector.id,
|
||||||
|
recipientEmail: inspector.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reportId) {
|
||||||
|
await this.upsertRow({
|
||||||
|
actId,
|
||||||
|
reportId,
|
||||||
|
documentKind: 'REPORT_WORD',
|
||||||
|
recipientKind: 'DIRECTOR',
|
||||||
|
recipientAssetId: null,
|
||||||
|
recipientUserId: null,
|
||||||
|
recipientKey: '00000000-0000-0000-0000-000000000000',
|
||||||
|
recipientEmail: settings?.directorEmail ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertRow(input: {
|
||||||
|
actId: string;
|
||||||
|
reportId: string | null;
|
||||||
|
documentKind: 'ACT_PDF' | 'REPORT_WORD';
|
||||||
|
recipientKind: DeliveryRecipientKind;
|
||||||
|
recipientAssetId: string | null;
|
||||||
|
recipientUserId: string | null;
|
||||||
|
recipientKey: string;
|
||||||
|
recipientEmail: string | null;
|
||||||
|
}) {
|
||||||
|
const initialStatus = input.recipientEmail ? 'PENDING' : 'WAITING_RECIPIENT';
|
||||||
|
await this.dataSource.query(`
|
||||||
|
INSERT INTO inspection_document_deliveries (
|
||||||
|
act_id,report_id,document_kind,recipient_kind,
|
||||||
|
recipient_asset_id,recipient_user_id,recipient_key,recipient_email,status
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid,$8,$9)
|
||||||
|
ON CONFLICT (act_id,document_kind,recipient_kind,recipient_key) DO UPDATE SET
|
||||||
|
report_id=COALESCE(EXCLUDED.report_id,inspection_document_deliveries.report_id),
|
||||||
|
recipient_asset_id=COALESCE(EXCLUDED.recipient_asset_id,inspection_document_deliveries.recipient_asset_id),
|
||||||
|
recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id),
|
||||||
|
recipient_email=CASE
|
||||||
|
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
|
||||||
|
ELSE EXCLUDED.recipient_email
|
||||||
|
END,
|
||||||
|
status=CASE
|
||||||
|
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
|
||||||
|
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
|
||||||
|
ELSE inspection_document_deliveries.status
|
||||||
|
END,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [
|
||||||
|
input.actId,
|
||||||
|
input.reportId,
|
||||||
|
input.documentKind,
|
||||||
|
input.recipientKind,
|
||||||
|
input.recipientAssetId,
|
||||||
|
input.recipientUserId,
|
||||||
|
input.recipientKey,
|
||||||
|
input.recipientEmail,
|
||||||
|
initialStatus,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
|
||||||
|
return this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||||
|
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||||
|
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||||
|
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||||
|
a.code AS "actCode",r.code AS "reportCode"
|
||||||
|
FROM inspection_document_deliveries d
|
||||||
|
JOIN inspection_acts a ON a.id=d.act_id
|
||||||
|
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||||
|
WHERE d.act_id=$1
|
||||||
|
ORDER BY d.created_at
|
||||||
|
`, [actId]) as Promise<DeliveryRow[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(id: string): Promise<DeliveryRow> {
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||||
|
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||||
|
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||||
|
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||||
|
a.code AS "actCode",r.code AS "reportCode"
|
||||||
|
FROM inspection_document_deliveries d
|
||||||
|
JOIN inspection_acts a ON a.id=d.act_id
|
||||||
|
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||||
|
WHERE d.id=$1
|
||||||
|
`, [id]) as DeliveryRow[];
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'DOCUMENT_DELIVERY_NOT_FOUND',
|
||||||
|
message: 'Entrega documental no encontrada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshRecipient(row: DeliveryRow) {
|
||||||
|
let email: string | null = null;
|
||||||
|
if (row.recipientKind === 'COMPANY' && row.recipientAssetId) {
|
||||||
|
const [company] = await this.dataSource.query(`
|
||||||
|
SELECT notification_email AS email
|
||||||
|
FROM organization_profiles
|
||||||
|
WHERE asset_id=$1
|
||||||
|
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
|
||||||
|
email = company?.email ?? null;
|
||||||
|
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
||||||
|
const [inspector] = await this.dataSource.query(`
|
||||||
|
SELECT email
|
||||||
|
FROM users
|
||||||
|
WHERE id=$1 AND is_active=true
|
||||||
|
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
||||||
|
email = inspector?.email ?? null;
|
||||||
|
} else {
|
||||||
|
const [settings] = await this.dataSource.query(`
|
||||||
|
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
||||||
|
FROM institutional_delivery_settings
|
||||||
|
WHERE id=1
|
||||||
|
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
||||||
|
email = row.recipientKind === 'OFFICE'
|
||||||
|
? settings?.officeEmail ?? null
|
||||||
|
: settings?.directorEmail ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.query(`
|
||||||
|
UPDATE inspection_document_deliveries
|
||||||
|
SET recipient_email=$2,
|
||||||
|
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
||||||
|
last_error=NULL,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1 AND status<>'SENT'
|
||||||
|
`, [row.id, email]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attempt(row: DeliveryRow) {
|
||||||
|
if (row.status === 'SENT') return;
|
||||||
|
if (!row.recipientEmail) {
|
||||||
|
await this.setStatus(row.id, 'WAITING_RECIPIENT', 'Destinatario no configurado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.smtp.configured()) {
|
||||||
|
await this.setStatus(row.id, 'WAITING_TRANSPORT', 'SMTP no configurado');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachment: { filename: string; mimeType: string; content: Buffer };
|
||||||
|
try {
|
||||||
|
if (row.documentKind === 'ACT_PDF') {
|
||||||
|
await this.pdf.ensure(row.actId);
|
||||||
|
const file = await this.pdf.content(row.actId);
|
||||||
|
attachment = {
|
||||||
|
filename: file.originalName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
content: file.buffer,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (!row.reportId) throw new Error('Informe no vinculado');
|
||||||
|
await this.word.ensure(row.reportId);
|
||||||
|
const file = await this.word.content(row.reportId);
|
||||||
|
const { readFile } = await import('node:fs/promises');
|
||||||
|
attachment = {
|
||||||
|
filename: file.originalName,
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
content: await readFile(file.filePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await this.setStatus(
|
||||||
|
row.id,
|
||||||
|
'WAITING_ARTIFACT',
|
||||||
|
error instanceof Error ? error.message : 'Documento no disponible',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.query(`
|
||||||
|
UPDATE inspection_document_deliveries
|
||||||
|
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
|
||||||
|
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
`, [row.id]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const label = row.documentKind === 'ACT_PDF'
|
||||||
|
? `Acta ${row.actCode}`
|
||||||
|
: `Informe ${row.reportCode ?? ''}`;
|
||||||
|
const text = row.documentKind === 'REPORT_WORD'
|
||||||
|
? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.`
|
||||||
|
: row.recipientKind === 'INSPECTOR'
|
||||||
|
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.`
|
||||||
|
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`;
|
||||||
|
const sent = await this.smtp.send({
|
||||||
|
to: row.recipientEmail,
|
||||||
|
subject: `DH Inspección · ${label}`,
|
||||||
|
text,
|
||||||
|
attachment,
|
||||||
|
});
|
||||||
|
await this.dataSource.query(`
|
||||||
|
UPDATE inspection_document_deliveries
|
||||||
|
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
|
||||||
|
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
`, [row.id, sent.messageId]);
|
||||||
|
await this.audit.record({
|
||||||
|
action: AuditAction.DOCUMENT_DELIVERY_SENT,
|
||||||
|
entityType: 'inspection_document_delivery',
|
||||||
|
entityId: row.id,
|
||||||
|
source: AuditSource.SYSTEM,
|
||||||
|
actorUserId: null,
|
||||||
|
actorUsername: null,
|
||||||
|
requestId: null,
|
||||||
|
ip: null,
|
||||||
|
userAgent: null,
|
||||||
|
beforeData: null,
|
||||||
|
afterData: {
|
||||||
|
recipientKind: row.recipientKind,
|
||||||
|
recipientUserId: row.recipientUserId,
|
||||||
|
documentKind: row.documentKind,
|
||||||
|
status: 'SENT',
|
||||||
|
},
|
||||||
|
metadata: { actId: row.actId, reportId: row.reportId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await this.setStatus(
|
||||||
|
row.id,
|
||||||
|
'FAILED',
|
||||||
|
error instanceof Error ? error.message : 'Error de entrega',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setStatus(id: string, status: string, error: string) {
|
||||||
|
await this.dataSource.query(`
|
||||||
|
UPDATE inspection_document_deliveries
|
||||||
|
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
`, [id, status, error.slice(0, 500)]);
|
||||||
}
|
}
|
||||||
private async setStatus(id:string,status:string,error:string){await this.dataSource.query(`UPDATE inspection_document_deliveries SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1`,[id,status,error.slice(0,500)]);}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsISO8601 } from 'class-validator';
|
||||||
|
|
||||||
|
export class CloseInspectionVisitDto {
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
clientClosedAt!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const optionalText = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hallazgo capturado desde la APK sobre un Inventario ya seleccionado.
|
||||||
|
* El assetId se toma de la URL para evitar inconsistencias entre pantalla y payload.
|
||||||
|
*/
|
||||||
|
export class CreateFieldFindingDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(optionalText)
|
||||||
|
@IsUUID('4')
|
||||||
|
catalogItemId?: string | null;
|
||||||
|
|
||||||
|
@ValidateIf((value: CreateFieldFindingDto) => !value.catalogItemId)
|
||||||
|
@Transform(optionalText)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(500)
|
||||||
|
customTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(optionalText)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(12000)
|
||||||
|
customLegalBasis?: string | null;
|
||||||
|
|
||||||
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(20000)
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10)
|
||||||
|
severity?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(optionalText)
|
||||||
|
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
correctionDueOn?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateFieldInventoryDto {
|
||||||
|
@IsUUID('4')
|
||||||
|
typeId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
familyId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toUpperCase() : undefined)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
@Matches(/^[A-Z0-9][A-Z0-9._/-]*$/)
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
commonName?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
discoveryNotes?: string | null;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
attributes!: Record<string, unknown>;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
deviceLatitude!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
deviceLongitude!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 3 })
|
||||||
|
@Min(0)
|
||||||
|
@Max(100000)
|
||||||
|
deviceAccuracyM?: number;
|
||||||
|
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
deviceCapturedAt!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
deviceLabel?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ListFieldInventoryQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : undefined)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
typeId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
limit = 50;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsISO8601,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class UploadFieldInventoryPhotoDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
title?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
deviceLatitude!: number;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
deviceLongitude!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 3 })
|
||||||
|
@Min(0)
|
||||||
|
@Max(100000)
|
||||||
|
deviceAccuracyM?: number;
|
||||||
|
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
deviceCapturedAt!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
deviceLabel?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
|
@Min(-90)
|
||||||
|
@Max(90)
|
||||||
|
exifLatitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
|
@Min(-180)
|
||||||
|
@Max(180)
|
||||||
|
exifLongitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
exifCapturedAt?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||||
|
import { AssetHistoryService } from '../asset-master/asset-history.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { AssetVersionChangeType, AuditAction } from '../database/entities';
|
||||||
|
import type { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
|
||||||
|
import { FieldInventoryService } from './field-inventory.service';
|
||||||
|
|
||||||
|
type FamilyRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||||
|
informationLabels: string[];
|
||||||
|
sourceReference: string | null;
|
||||||
|
isOther: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParentRow = {
|
||||||
|
id: string;
|
||||||
|
typeCode: string;
|
||||||
|
inventoryFamilyId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldTypesBase = {
|
||||||
|
context: {
|
||||||
|
area: { id: string; code: string; name: string };
|
||||||
|
operatorCompany: { id: string; code: string; name: string };
|
||||||
|
inspection: { id: string; code: string; status: string };
|
||||||
|
};
|
||||||
|
parent: { id: string; code: string; name: string };
|
||||||
|
data: Array<Record<string, unknown> & { id: string; code: string; name: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STRUCTURAL_CHILD: Record<string, string | undefined> = {
|
||||||
|
area: 'yacimiento',
|
||||||
|
yacimiento: 'instalacion',
|
||||||
|
instalacion: 'subinstalacion',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class F3FieldInventoryStructureService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly fieldInventory: FieldInventoryService,
|
||||||
|
private readonly history: AssetHistoryService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async types(
|
||||||
|
visitId: string,
|
||||||
|
parentId: string | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
) {
|
||||||
|
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase;
|
||||||
|
const effectiveParentId = parentId ?? base.context.area.id;
|
||||||
|
const parent = await this.parent(effectiveParentId);
|
||||||
|
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
|
||||||
|
const data = expectedTypeCode
|
||||||
|
? base.data.filter((type) => type.code.toLowerCase() === expectedTypeCode)
|
||||||
|
: [];
|
||||||
|
const families = await this.familiesFor(parent, expectedTypeCode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
data: data.map((type) => ({
|
||||||
|
...type,
|
||||||
|
structuralKind: type.code.toUpperCase(),
|
||||||
|
families,
|
||||||
|
familyRequired: type.code.toLowerCase() === 'instalacion' || type.code.toLowerCase() === 'subinstalacion',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
visitId: string,
|
||||||
|
dto: CreateFieldInventoryDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const options = await this.types(visitId, dto.parentId, principal);
|
||||||
|
const selectedType = options.data.find((type) => type.id === dto.typeId);
|
||||||
|
if (!selectedType) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_INVENTORY_STRUCTURE_TYPE_INVALID',
|
||||||
|
message: 'El tipo elegido no corresponde al siguiente nivel estructural permitido',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeCode = String(selectedType.code).toLowerCase();
|
||||||
|
const familyRequired = typeCode === 'instalacion' || typeCode === 'subinstalacion';
|
||||||
|
const families = (selectedType.families ?? []) as FamilyRow[];
|
||||||
|
if (familyRequired && !dto.familyId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_INVENTORY_FAMILY_REQUIRED',
|
||||||
|
message: 'Elegí la familia técnica o la opción Otro / no catalogado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!familyRequired && dto.familyId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_INVENTORY_FAMILY_NOT_ALLOWED',
|
||||||
|
message: 'Este nivel estructural no utiliza familia técnica',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const family = dto.familyId
|
||||||
|
? families.find((item) => item.id === dto.familyId)
|
||||||
|
: null;
|
||||||
|
if (dto.familyId && !family) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_INVENTORY_FAMILY_INVALID',
|
||||||
|
message: 'La familia técnica no es válida para el padre seleccionado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await this.fieldInventory.create(visitId, dto, principal, request) as {
|
||||||
|
asset: { id: string; code: string; name: string };
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
if (!family) return created;
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const [current] = await manager.query(`
|
||||||
|
SELECT inventory_family_id AS "inventoryFamilyId"
|
||||||
|
FROM assets WHERE id=$1::uuid FOR UPDATE
|
||||||
|
`, [created.asset.id]) as Array<{ inventoryFamilyId: string | null }>;
|
||||||
|
if (!current) throw new ConflictException({
|
||||||
|
code: 'FIELD_INVENTORY_CREATED_ASSET_MISSING',
|
||||||
|
message: 'No se pudo clasificar el Inventario recién creado',
|
||||||
|
});
|
||||||
|
if (current.inventoryFamilyId === family.id) return;
|
||||||
|
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE assets
|
||||||
|
SET inventory_family_id=$2::uuid,updated_by=$3::uuid,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1::uuid
|
||||||
|
`, [created.asset.id, family.id, principal.userId]);
|
||||||
|
const versionNumber = await this.history.capture(
|
||||||
|
manager,
|
||||||
|
created.asset.id,
|
||||||
|
AssetVersionChangeType.UPDATED,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.ASSET_UPDATED,
|
||||||
|
entityType: 'asset',
|
||||||
|
entityId: created.asset.id,
|
||||||
|
beforeData: { inventoryFamilyId: current.inventoryFamilyId },
|
||||||
|
afterData: {
|
||||||
|
inventoryFamilyId: family.id,
|
||||||
|
inventoryFamilyCode: family.code,
|
||||||
|
inventoryFamilyName: family.name,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
operation: 'FIELD_INVENTORY_FAMILY_ASSIGNED',
|
||||||
|
visitId,
|
||||||
|
versionNumber,
|
||||||
|
isOtherFamily: family.isOther,
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.fieldInventory.detail(visitId, created.asset.id, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parent(parentId: string): Promise<ParentRow> {
|
||||||
|
const rows = await this.dataSource.query(`
|
||||||
|
SELECT asset.id,type.code AS "typeCode",asset.inventory_family_id AS "inventoryFamilyId"
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
|
||||||
|
`, [parentId]) as ParentRow[];
|
||||||
|
if (!rows[0]) throw new BadRequestException({
|
||||||
|
code: 'FIELD_INVENTORY_PARENT_NOT_AVAILABLE',
|
||||||
|
message: 'El padre estructural elegido no está disponible',
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async familiesFor(parent: ParentRow, expectedTypeCode: string | undefined): Promise<FamilyRow[]> {
|
||||||
|
if (expectedTypeCode === 'instalacion') {
|
||||||
|
return this.dataSource.query(`
|
||||||
|
SELECT id,code,name,level,information_labels AS "informationLabels",
|
||||||
|
source_reference AS "sourceReference",
|
||||||
|
(source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
|
||||||
|
FROM inventory_families
|
||||||
|
WHERE level='INSTALLATION' AND is_active=true
|
||||||
|
ORDER BY (source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,name,code
|
||||||
|
`) as Promise<FamilyRow[]>;
|
||||||
|
}
|
||||||
|
if (expectedTypeCode === 'subinstalacion') {
|
||||||
|
if (!parent.inventoryFamilyId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_INVENTORY_PARENT_FAMILY_REQUIRED',
|
||||||
|
message: 'La Instalación debe tener una familia técnica antes de agregar Subinstalaciones',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.dataSource.query(`
|
||||||
|
SELECT family.id,family.code,family.name,family.level,
|
||||||
|
family.information_labels AS "informationLabels",
|
||||||
|
family.source_reference AS "sourceReference",
|
||||||
|
(family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
|
||||||
|
FROM inventory_family_parent_rules rule
|
||||||
|
JOIN inventory_families family ON family.id=rule.child_family_id
|
||||||
|
WHERE rule.parent_family_id=$1::uuid
|
||||||
|
AND family.level='SUBINSTALLATION'
|
||||||
|
AND family.is_active=true
|
||||||
|
ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,family.name,family.code
|
||||||
|
`, [parent.inventoryFamilyId]) as Promise<FamilyRow[]>;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
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 { CreateFieldFindingDto } from './dto/create-field-finding.dto';
|
||||||
|
import { FieldFindingsService } from './field-findings.service';
|
||||||
|
|
||||||
|
@Controller('inspection-visits/:visitId/field-findings')
|
||||||
|
export class FieldFindingsController {
|
||||||
|
constructor(private readonly fieldFindings: FieldFindingsService) {}
|
||||||
|
|
||||||
|
@Get(':assetId/options')
|
||||||
|
@RequirePermissions('inspection_findings.create', 'inspections.execute')
|
||||||
|
options(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.fieldFindings.options(visitId, assetId, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':assetId')
|
||||||
|
@RequirePermissions('inspection_findings.read', 'inspections.execute')
|
||||||
|
list(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.fieldFindings.list(visitId, assetId, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':assetId')
|
||||||
|
@RequirePermissions('inspection_findings.create', 'inspections.execute')
|
||||||
|
create(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@Body() dto: CreateFieldFindingDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.fieldFindings.create(visitId, assetId, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import type { CreateInspectionFindingDto } from '../inspection-findings/dto/create-inspection-finding.dto';
|
||||||
|
import { F3FindingCatalogResolverService } from '../inspection-findings/f3-finding-catalog-resolver.service';
|
||||||
|
import { InspectionFindingsService } from '../inspection-findings/inspection-findings.service';
|
||||||
|
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
||||||
|
import type { CreateFieldFindingDto } from './dto/create-field-finding.dto';
|
||||||
|
|
||||||
|
interface DraftActRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldFindingGate {
|
||||||
|
context: {
|
||||||
|
inspection: { id: string; code: string; status: string };
|
||||||
|
area: { id: string; code: string; name: string };
|
||||||
|
operatorCompany: { id: string; code: string; name: string };
|
||||||
|
};
|
||||||
|
capture: {
|
||||||
|
captureRequired: boolean;
|
||||||
|
hasGeometry: boolean;
|
||||||
|
creationGpsCaptured: boolean;
|
||||||
|
fieldPhotoCount: number;
|
||||||
|
readyForFinding: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FieldFindingsService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly catalog: F3FindingCatalogResolverService,
|
||||||
|
private readonly findings: InspectionFindingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async options(visitId: string, assetId: string, principal: AuthPrincipal) {
|
||||||
|
const gate = await this.requireGate(visitId, assetId, principal);
|
||||||
|
const act = await this.requireDraftAct(visitId);
|
||||||
|
const [catalog, findings] = await Promise.all([
|
||||||
|
this.catalog.listApplicableForAsset(assetId, {}),
|
||||||
|
this.findings.listForAct(act.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: gate.context,
|
||||||
|
act,
|
||||||
|
capture: gate.capture,
|
||||||
|
catalog,
|
||||||
|
findings: findings.data.filter((finding) => finding.assetId === assetId),
|
||||||
|
canAddAnother: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(visitId: string, assetId: string, principal: AuthPrincipal) {
|
||||||
|
const gate = await this.requireGate(visitId, assetId, principal);
|
||||||
|
const act = await this.requireDraftAct(visitId);
|
||||||
|
const findings = await this.findings.listForAct(act.id);
|
||||||
|
return {
|
||||||
|
context: gate.context,
|
||||||
|
act,
|
||||||
|
capture: gate.capture,
|
||||||
|
data: findings.data.filter((finding) => finding.assetId === assetId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
visitId: string,
|
||||||
|
assetId: string,
|
||||||
|
dto: CreateFieldFindingDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const gate = await this.requireGate(visitId, assetId, principal);
|
||||||
|
const act = await this.requireDraftAct(visitId);
|
||||||
|
const payload: CreateInspectionFindingDto = {
|
||||||
|
assetId,
|
||||||
|
catalogItemId: dto.catalogItemId ?? null,
|
||||||
|
customTitle: dto.customTitle ?? null,
|
||||||
|
customLegalBasis: dto.customLegalBasis ?? null,
|
||||||
|
description: dto.description,
|
||||||
|
severity: dto.severity,
|
||||||
|
correctionDueOn: dto.correctionDueOn ?? null,
|
||||||
|
};
|
||||||
|
const finding = await this.findings.create(act.id, payload, principal, request);
|
||||||
|
return {
|
||||||
|
context: gate.context,
|
||||||
|
act,
|
||||||
|
capture: gate.capture,
|
||||||
|
finding,
|
||||||
|
canAddAnother: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireGate(
|
||||||
|
visitId: string,
|
||||||
|
assetId: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
): Promise<FieldFindingGate> {
|
||||||
|
assertMobileInspector(principal);
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
visit.id AS "visitId",
|
||||||
|
visit.code AS "visitCode",
|
||||||
|
visit.status AS "visitStatus",
|
||||||
|
visit.operational_area_id AS "areaId",
|
||||||
|
area.code AS "areaCode",
|
||||||
|
area.name AS "areaName",
|
||||||
|
visit.operator_company_id AS "companyId",
|
||||||
|
company.code AS "companyCode",
|
||||||
|
company.name AS "companyName",
|
||||||
|
asset.id AS "assetId",
|
||||||
|
asset_type.code AS "assetTypeCode",
|
||||||
|
asset.inventory_family_id AS "inventoryFamilyId",
|
||||||
|
asset.operational_area_id AS "assetAreaId",
|
||||||
|
asset.operator_company_id AS "assetCompanyId",
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM inspection_visit_members member
|
||||||
|
WHERE member.visit_id = visit.id
|
||||||
|
AND member.user_id = $3::uuid
|
||||||
|
AND member.included = true
|
||||||
|
) OR visit.lead_inspector_user_id = $3::uuid AS assigned,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM inspection_visit_assets link
|
||||||
|
WHERE link.visit_id = visit.id
|
||||||
|
AND link.asset_id = asset.id
|
||||||
|
AND link.included = true
|
||||||
|
) AS selected,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM asset_field_discoveries discovery
|
||||||
|
WHERE discovery.visit_id = visit.id
|
||||||
|
AND discovery.asset_id = asset.id
|
||||||
|
) AS "captureRequired",
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM asset_geometries geometry
|
||||||
|
WHERE geometry.asset_id = asset.id
|
||||||
|
) AS "hasGeometry",
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM asset_field_capture_events event
|
||||||
|
WHERE event.visit_id = visit.id
|
||||||
|
AND event.asset_id = asset.id
|
||||||
|
AND event.event_type = 'CREATED'
|
||||||
|
) AS "creationGpsCaptured",
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM asset_field_capture_events event
|
||||||
|
WHERE event.visit_id = visit.id
|
||||||
|
AND event.asset_id = asset.id
|
||||||
|
AND event.event_type = 'PHOTO'
|
||||||
|
) AS "fieldPhotoCount"
|
||||||
|
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 assets asset ON asset.id = $2::uuid
|
||||||
|
AND asset.information_status <> 'INACTIVE'
|
||||||
|
LEFT JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
|
||||||
|
WHERE visit.id = $1::uuid
|
||||||
|
`, [visitId, assetId, principal.userId]) as Array<{
|
||||||
|
visitId: string;
|
||||||
|
visitCode: string;
|
||||||
|
visitStatus: string;
|
||||||
|
areaId: string | null;
|
||||||
|
areaCode: string | null;
|
||||||
|
areaName: string | null;
|
||||||
|
companyId: string | null;
|
||||||
|
companyCode: string | null;
|
||||||
|
companyName: string | null;
|
||||||
|
assetId: string | null;
|
||||||
|
assetTypeCode: string | null;
|
||||||
|
inventoryFamilyId: string | null;
|
||||||
|
assetAreaId: string | null;
|
||||||
|
assetCompanyId: string | null;
|
||||||
|
assigned: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
captureRequired: boolean;
|
||||||
|
hasGeometry: boolean;
|
||||||
|
creationGpsCaptured: boolean;
|
||||||
|
fieldPhotoCount: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
|
||||||
|
}
|
||||||
|
if (row.visitStatus !== 'IN_PROGRESS') {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_FINDING_VISIT_NOT_IN_PROGRESS',
|
||||||
|
message: 'Los Hallazgos sólo pueden registrarse cuando la inspección está en curso',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!row.assigned) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_FINDING_INSPECTOR_NOT_ASSIGNED',
|
||||||
|
message: 'El inspector no está asignado a esta inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!row.areaId || !row.companyId || !row.areaCode || !row.areaName || !row.companyCode || !row.companyName) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_FINDING_CONTEXT_REQUIRED',
|
||||||
|
message: 'La inspección no tiene Área y Operadora definidas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!row.assetId) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
|
||||||
|
message: 'Registro de Inventario no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED',
|
||||||
|
message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!row.inventoryFamilyId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_FINDING_FAMILY_REQUIRED',
|
||||||
|
message: 'La Instalación/Subinstalación debe tener una familia técnica u Otro antes de registrar Hallazgos',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
|
||||||
|
message: 'El Inventario no pertenece al Área y Operadora de esta inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!row.selected) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_FINDING_INVENTORY_NOT_SELECTED',
|
||||||
|
message: 'Seleccioná el Inventario dentro de la inspección antes de registrar un Hallazgo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureRequired = Boolean(row.captureRequired);
|
||||||
|
const hasGeometry = Boolean(row.hasGeometry);
|
||||||
|
const creationGpsCaptured = Boolean(row.creationGpsCaptured);
|
||||||
|
const fieldPhotoCount = Number(row.fieldPhotoCount ?? 0);
|
||||||
|
const readyForFinding = !captureRequired || (
|
||||||
|
hasGeometry && creationGpsCaptured && fieldPhotoCount > 0
|
||||||
|
);
|
||||||
|
if (!readyForFinding) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_FINDING_CAPTURE_REQUIRED',
|
||||||
|
message: 'Antes del Hallazgo, el Inventario creado en campo debe tener GPS y al menos una foto',
|
||||||
|
capture: { captureRequired, hasGeometry, creationGpsCaptured, fieldPhotoCount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: {
|
||||||
|
inspection: { id: row.visitId, code: row.visitCode, status: row.visitStatus },
|
||||||
|
area: { id: row.areaId, code: row.areaCode, name: row.areaName },
|
||||||
|
operatorCompany: { id: row.companyId, code: row.companyCode, name: row.companyName },
|
||||||
|
},
|
||||||
|
capture: {
|
||||||
|
captureRequired,
|
||||||
|
hasGeometry,
|
||||||
|
creationGpsCaptured,
|
||||||
|
fieldPhotoCount,
|
||||||
|
readyForFinding,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireDraftAct(visitId: string): Promise<DraftActRow> {
|
||||||
|
const rows = await this.dataSource.query(`
|
||||||
|
SELECT id, code, status
|
||||||
|
FROM inspection_acts
|
||||||
|
WHERE visit_id = $1::uuid
|
||||||
|
AND status = 'DRAFT'
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT 2
|
||||||
|
`, [visitId]) as DraftActRow[];
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_FINDING_DRAFT_ACT_REQUIRED',
|
||||||
|
message: 'La inspección no tiene un Acta borrador abierta para registrar Hallazgos',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rows.length > 1) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_FINDING_MULTIPLE_DRAFT_ACTS',
|
||||||
|
message: 'La inspección tiene más de un Acta borrador. Debe resolverse antes de continuar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { MAX_ASSET_MEDIA_BYTES, type UploadedAssetFile } from '../asset-master/asset-media-file';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
|
||||||
|
import { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto';
|
||||||
|
import { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto';
|
||||||
|
import { F3FieldInventoryStructureService } from './f3-field-inventory-structure.service';
|
||||||
|
import { FieldInventoryService } from './field-inventory.service';
|
||||||
|
|
||||||
|
@Controller('inspection-visits/:visitId/field-inventory')
|
||||||
|
export class FieldInventoryController {
|
||||||
|
constructor(
|
||||||
|
private readonly fieldInventory: FieldInventoryService,
|
||||||
|
private readonly structure: F3FieldInventoryStructureService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('assets.read', 'inspections.execute')
|
||||||
|
list(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Query() query: ListFieldInventoryQueryDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
) {
|
||||||
|
return this.fieldInventory.list(visitId, query, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('types')
|
||||||
|
@RequirePermissions('assets.read', 'inspections.execute')
|
||||||
|
types(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Query() query: ListFieldInventoryQueryDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.structure.types(visitId, query.parentId, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':assetId')
|
||||||
|
@RequirePermissions('assets.read', 'inspections.execute')
|
||||||
|
detail(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
) {
|
||||||
|
return this.fieldInventory.detail(visitId, assetId, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':assetId/select')
|
||||||
|
@RequirePermissions('assets.read', 'inspections.execute')
|
||||||
|
selectExisting(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
) {
|
||||||
|
return this.fieldInventory.selectExisting(visitId, assetId, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('assets.create', 'inspections.execute')
|
||||||
|
create(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Body() dto: CreateFieldInventoryDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.structure.create(visitId, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':assetId/photos')
|
||||||
|
@RequirePermissions('assets.create', 'inspections.execute')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_ASSET_MEDIA_BYTES, files: 1 },
|
||||||
|
}))
|
||||||
|
uploadPhoto(
|
||||||
|
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||||
|
@Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string,
|
||||||
|
@Body() dto: UploadFieldInventoryPhotoDto,
|
||||||
|
@UploadedFile() file: UploadedAssetFile | undefined,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.fieldInventory.uploadPhoto(visitId, assetId, dto, file, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AssetGeometriesService } from '../asset-master/asset-geometries.service';
|
||||||
|
import { AssetMediaService, type AssetMediaView } from '../asset-master/asset-media.service';
|
||||||
|
import { AssetsService } from '../asset-master/assets.service';
|
||||||
|
import type { UploadedAssetFile } from '../asset-master/asset-media-file';
|
||||||
|
import type { CreateFieldDiscoveryDto } from '../asset-master/dto/create-field-discovery.dto';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { AssetGeometryType, AssetMediaKind } from '../database/entities';
|
||||||
|
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
|
||||||
|
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
||||||
|
import type { CreateFieldInventoryDto } from './dto/create-field-inventory.dto';
|
||||||
|
import type { ListFieldInventoryQueryDto } from './dto/list-field-inventory-query.dto';
|
||||||
|
import type { UploadFieldInventoryPhotoDto } from './dto/upload-field-inventory-photo.dto';
|
||||||
|
|
||||||
|
interface MobileVisitContext {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
areaId: string;
|
||||||
|
areaCode: string;
|
||||||
|
areaName: string;
|
||||||
|
companyId: string;
|
||||||
|
companyCode: string;
|
||||||
|
companyName: string;
|
||||||
|
assigned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldDiscoveryCreated {
|
||||||
|
id: string;
|
||||||
|
asset: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
informationStatus: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldInventoryCaptureStatus {
|
||||||
|
captureRequired: boolean;
|
||||||
|
hasGeometry: boolean;
|
||||||
|
creationGpsCaptured: boolean;
|
||||||
|
fieldPhotoCount: number;
|
||||||
|
readyForFinding: boolean;
|
||||||
|
latestDeviceCapture: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
accuracyM: number | null;
|
||||||
|
capturedAt: Date;
|
||||||
|
deviceLabel: string | null;
|
||||||
|
} | null;
|
||||||
|
latestPhotoExif: {
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
capturedAt: Date | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FieldInventoryService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly assets: AssetsService,
|
||||||
|
private readonly geometries: AssetGeometriesService,
|
||||||
|
private readonly media: AssetMediaService,
|
||||||
|
private readonly links: FieldDiscoveryInspectionLinkService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(
|
||||||
|
visitId: string,
|
||||||
|
query: ListFieldInventoryQueryDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
) {
|
||||||
|
const context = await this.requireVisitContext(visitId, principal, false);
|
||||||
|
if (query.parentId) await this.requireParentInContext(query.parentId, context);
|
||||||
|
|
||||||
|
const args: unknown[] = [context.areaId, context.companyId, visitId];
|
||||||
|
const conditions = [
|
||||||
|
'asset.operational_area_id = $1::uuid',
|
||||||
|
'asset.operator_company_id = $2::uuid',
|
||||||
|
"asset.information_status <> 'INACTIVE'",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (query.search?.trim()) {
|
||||||
|
args.push(`%${query.search.trim()}%`);
|
||||||
|
const p = `$${args.length}`;
|
||||||
|
conditions.push(`(
|
||||||
|
asset.code ILIKE ${p}
|
||||||
|
OR asset.name ILIKE ${p}
|
||||||
|
OR asset.common_name ILIKE ${p}
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM asset_attribute_values value
|
||||||
|
JOIN asset_attribute_definitions definition ON definition.id = value.definition_id
|
||||||
|
WHERE value.asset_id = asset.id
|
||||||
|
AND definition.is_active = true
|
||||||
|
AND (value.value #>> '{}') ILIKE ${p}
|
||||||
|
)
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.parentId) {
|
||||||
|
args.push(query.parentId);
|
||||||
|
conditions.push(`asset.parent_id = $${args.length}::uuid`);
|
||||||
|
}
|
||||||
|
if (query.typeId) {
|
||||||
|
args.push(query.typeId);
|
||||||
|
conditions.push(`asset.asset_type_id = $${args.length}::uuid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(query.limit);
|
||||||
|
const limit = `$${args.length}`;
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
asset.id,
|
||||||
|
asset.code,
|
||||||
|
asset.name,
|
||||||
|
asset.common_name AS "commonName",
|
||||||
|
asset.information_status AS "informationStatus",
|
||||||
|
asset.data_origin AS "dataOrigin",
|
||||||
|
JSONB_BUILD_OBJECT('id', type.id, 'code', type.code, 'name', type.name) AS type,
|
||||||
|
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id', parent.id, 'code', parent.code, 'name', parent.name
|
||||||
|
) END AS parent,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM inspection_visit_assets link
|
||||||
|
WHERE link.visit_id = $3::uuid
|
||||||
|
AND link.asset_id = asset.id
|
||||||
|
AND link.included = true
|
||||||
|
) AS "selectedInInspection",
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM asset_field_discoveries discovery
|
||||||
|
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
|
||||||
|
) AS "captureRequired",
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id
|
||||||
|
) AS "hasGeometry",
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM asset_field_capture_events capture
|
||||||
|
WHERE capture.visit_id = $3::uuid
|
||||||
|
AND capture.asset_id = asset.id
|
||||||
|
AND capture.event_type = 'PHOTO'
|
||||||
|
) AS "fieldPhotoCount",
|
||||||
|
(
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM asset_field_discoveries discovery
|
||||||
|
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM asset_field_capture_events capture
|
||||||
|
WHERE capture.visit_id = $3::uuid
|
||||||
|
AND capture.asset_id = asset.id
|
||||||
|
AND capture.event_type = 'CREATED'
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM asset_field_capture_events capture
|
||||||
|
WHERE capture.visit_id = $3::uuid
|
||||||
|
AND capture.asset_id = asset.id
|
||||||
|
AND capture.event_type = 'PHOTO'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) AS "readyForFinding"
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id = asset.asset_type_id
|
||||||
|
LEFT JOIN assets parent ON parent.id = asset.parent_id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY asset.name, asset.code
|
||||||
|
LIMIT ${limit}
|
||||||
|
`, args);
|
||||||
|
|
||||||
|
return { context: this.publicContext(context), data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async types(
|
||||||
|
visitId: string,
|
||||||
|
parentId: string | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
) {
|
||||||
|
const context = await this.requireVisitContext(visitId, principal, false);
|
||||||
|
const effectiveParentId = parentId ?? context.areaId;
|
||||||
|
const parent = await this.requireParentInContext(effectiveParentId, context);
|
||||||
|
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
type.id,
|
||||||
|
type.code,
|
||||||
|
type.name,
|
||||||
|
type.description,
|
||||||
|
COALESCE(
|
||||||
|
JSONB_AGG(
|
||||||
|
JSONB_BUILD_OBJECT(
|
||||||
|
'id', 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
|
||||||
|
) ORDER BY definition.sort_order, definition.name
|
||||||
|
) FILTER (WHERE definition.id IS NOT NULL),
|
||||||
|
'[]'::jsonb
|
||||||
|
) AS attributes
|
||||||
|
FROM asset_type_parent_rules rule
|
||||||
|
JOIN asset_types type ON type.id = rule.child_type_id
|
||||||
|
LEFT JOIN asset_attribute_definitions definition
|
||||||
|
ON definition.asset_type_id = type.id
|
||||||
|
AND definition.is_active = true
|
||||||
|
WHERE rule.parent_type_id = $1::uuid
|
||||||
|
AND type.is_active = true
|
||||||
|
AND type.operational_role = 'GENERIC'
|
||||||
|
GROUP BY type.id, type.code, type.name, type.description
|
||||||
|
ORDER BY type.name, type.code
|
||||||
|
`, [parent.typeId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: this.publicContext(context),
|
||||||
|
parent: { id: parent.id, code: parent.code, name: parent.name },
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(visitId: string, assetId: string, principal: AuthPrincipal) {
|
||||||
|
const context = await this.requireVisitContext(visitId, principal, false);
|
||||||
|
await this.requireAssetInContext(assetId, context);
|
||||||
|
return {
|
||||||
|
context: this.publicContext(context),
|
||||||
|
asset: await this.assets.getById(assetId),
|
||||||
|
selectedInInspection: await this.isSelected(visitId, assetId),
|
||||||
|
capture: await this.captureStatus(visitId, assetId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectExisting(
|
||||||
|
visitId: string,
|
||||||
|
assetId: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
) {
|
||||||
|
const context = await this.requireVisitContext(visitId, principal, true);
|
||||||
|
await this.requireAssetInContext(assetId, context);
|
||||||
|
await this.dataSource.transaction((manager) =>
|
||||||
|
this.links.attach(manager, visitId, assetId, principal.userId),
|
||||||
|
);
|
||||||
|
return this.detail(visitId, assetId, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
visitId: string,
|
||||||
|
dto: CreateFieldInventoryDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const context = await this.requireVisitContext(visitId, principal, true);
|
||||||
|
const parentId = dto.parentId ?? context.areaId;
|
||||||
|
await this.requireParentInContext(parentId, context);
|
||||||
|
|
||||||
|
const capturedAt = new Date(dto.deviceCapturedAt);
|
||||||
|
if (!Number.isFinite(capturedAt.getTime())) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_INVENTORY_INVALID_CAPTURE_DATE',
|
||||||
|
message: 'La fecha de captura del dispositivo no es válida',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = dto.code?.trim().toUpperCase() || await this.nextFieldCode(capturedAt);
|
||||||
|
const discoveryDto: CreateFieldDiscoveryDto = {
|
||||||
|
visitId,
|
||||||
|
code,
|
||||||
|
name: dto.name,
|
||||||
|
commonName: dto.commonName ?? null,
|
||||||
|
typeId: dto.typeId,
|
||||||
|
parentId,
|
||||||
|
operationalAreaId: context.areaId,
|
||||||
|
operatorCompanyId: context.companyId,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
discoveryNotes: dto.discoveryNotes ?? null,
|
||||||
|
attributes: dto.attributes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const discovery = await this.assets.createFieldDiscovery(
|
||||||
|
discoveryDto,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
) as FieldDiscoveryCreated;
|
||||||
|
const assetId = discovery.asset?.id;
|
||||||
|
if (!assetId) {
|
||||||
|
throw new Error('El alta de campo no devolvió el identificador del Inventario creado');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.geometries.upsert(assetId, {
|
||||||
|
geometry: {
|
||||||
|
type: AssetGeometryType.POINT,
|
||||||
|
coordinates: [dto.deviceLongitude, dto.deviceLatitude],
|
||||||
|
},
|
||||||
|
accuracyM: dto.deviceAccuracyM ?? null,
|
||||||
|
capturedAt: dto.deviceCapturedAt,
|
||||||
|
deviceLabel: dto.deviceLabel ?? null,
|
||||||
|
}, principal, request);
|
||||||
|
|
||||||
|
await this.dataSource.query(`
|
||||||
|
INSERT INTO asset_field_capture_events (
|
||||||
|
visit_id, asset_id, event_type,
|
||||||
|
device_latitude, device_longitude, device_accuracy_m,
|
||||||
|
device_captured_at, device_label, created_by
|
||||||
|
) VALUES ($1,$2,'CREATED',$3,$4,$5,$6,$7,$8)
|
||||||
|
`, [
|
||||||
|
visitId,
|
||||||
|
assetId,
|
||||||
|
dto.deviceLatitude,
|
||||||
|
dto.deviceLongitude,
|
||||||
|
dto.deviceAccuracyM ?? null,
|
||||||
|
capturedAt,
|
||||||
|
dto.deviceLabel ?? null,
|
||||||
|
principal.userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return this.detail(visitId, assetId, principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadPhoto(
|
||||||
|
visitId: string,
|
||||||
|
assetId: string,
|
||||||
|
dto: UploadFieldInventoryPhotoDto,
|
||||||
|
file: UploadedAssetFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<{ media: AssetMediaView; capture: FieldInventoryCaptureStatus }> {
|
||||||
|
const context = await this.requireVisitContext(visitId, principal, true);
|
||||||
|
await this.requireAssetInContext(assetId, context);
|
||||||
|
if (!await this.isSelected(visitId, assetId)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_INVENTORY_NOT_SELECTED',
|
||||||
|
message: 'Seleccioná el registro dentro de la inspección antes de agregar fotografías',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const exifHasLatitude = dto.exifLatitude !== undefined;
|
||||||
|
const exifHasLongitude = dto.exifLongitude !== undefined;
|
||||||
|
if (exifHasLatitude !== exifHasLongitude) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_PHOTO_EXIF_COORDINATES_INCOMPLETE',
|
||||||
|
message: 'Las coordenadas EXIF deben incluir latitud y longitud juntas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploaded = await this.media.upload(assetId, {
|
||||||
|
kind: AssetMediaKind.PHOTO,
|
||||||
|
title: dto.title ?? undefined,
|
||||||
|
description: dto.description ?? undefined,
|
||||||
|
capturedAt: dto.deviceCapturedAt,
|
||||||
|
latitude: dto.deviceLatitude,
|
||||||
|
longitude: dto.deviceLongitude,
|
||||||
|
accuracyM: dto.deviceAccuracyM,
|
||||||
|
}, file, principal, request);
|
||||||
|
|
||||||
|
await this.dataSource.query(`
|
||||||
|
INSERT INTO asset_field_capture_events (
|
||||||
|
visit_id, asset_id, media_id, event_type,
|
||||||
|
device_latitude, device_longitude, device_accuracy_m,
|
||||||
|
device_captured_at, device_label,
|
||||||
|
exif_latitude, exif_longitude, exif_captured_at,
|
||||||
|
created_by
|
||||||
|
) VALUES ($1,$2,$3,'PHOTO',$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
|
`, [
|
||||||
|
visitId,
|
||||||
|
assetId,
|
||||||
|
uploaded.id,
|
||||||
|
dto.deviceLatitude,
|
||||||
|
dto.deviceLongitude,
|
||||||
|
dto.deviceAccuracyM ?? null,
|
||||||
|
new Date(dto.deviceCapturedAt),
|
||||||
|
dto.deviceLabel ?? null,
|
||||||
|
dto.exifLatitude ?? null,
|
||||||
|
dto.exifLongitude ?? null,
|
||||||
|
dto.exifCapturedAt ? new Date(dto.exifCapturedAt) : null,
|
||||||
|
principal.userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { media: uploaded, capture: await this.captureStatus(visitId, assetId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireVisitContext(
|
||||||
|
visitId: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
requireInProgress: boolean,
|
||||||
|
): Promise<MobileVisitContext> {
|
||||||
|
assertMobileInspector(principal);
|
||||||
|
const [context] = (await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
visit.id,
|
||||||
|
visit.code,
|
||||||
|
visit.status,
|
||||||
|
visit.operational_area_id AS "areaId",
|
||||||
|
area.code AS "areaCode",
|
||||||
|
area.name AS "areaName",
|
||||||
|
visit.operator_company_id AS "companyId",
|
||||||
|
company.code AS "companyCode",
|
||||||
|
company.name AS "companyName",
|
||||||
|
(
|
||||||
|
visit.lead_inspector_user_id = $2::uuid
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM inspection_visit_members member
|
||||||
|
WHERE member.visit_id = visit.id
|
||||||
|
AND member.user_id = $2::uuid
|
||||||
|
AND member.included = true
|
||||||
|
)
|
||||||
|
) AS assigned
|
||||||
|
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
|
||||||
|
WHERE visit.id = $1::uuid
|
||||||
|
`, [visitId, principal.userId])) as MobileVisitContext[];
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
|
||||||
|
}
|
||||||
|
if (!context.areaId || !context.companyId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_INVENTORY_CONTEXT_REQUIRED',
|
||||||
|
message: 'La inspección no tiene Área y Operadora definidas',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!context.assigned) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED',
|
||||||
|
message: 'El inspector no está asignado a esta inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (requireInProgress && context.status !== 'IN_PROGRESS') {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_INVENTORY_VISIT_NOT_IN_PROGRESS',
|
||||||
|
message: 'El Inventario sólo puede modificarse cuando la inspección está en curso',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!requireInProgress && !['PLANNED', 'IN_PROGRESS'].includes(context.status)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FIELD_INVENTORY_VISIT_NOT_AVAILABLE',
|
||||||
|
message: 'El Inventario de campo está disponible para inspecciones planificadas o en curso',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private publicContext(context: MobileVisitContext) {
|
||||||
|
return {
|
||||||
|
inspection: { id: context.id, code: context.code, status: context.status },
|
||||||
|
area: { id: context.areaId, code: context.areaCode, name: context.areaName },
|
||||||
|
operatorCompany: {
|
||||||
|
id: context.companyId,
|
||||||
|
code: context.companyCode,
|
||||||
|
name: context.companyName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireParentInContext(parentId: string, context: MobileVisitContext) {
|
||||||
|
const [parent] = (await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
asset.id,
|
||||||
|
asset.code,
|
||||||
|
asset.name,
|
||||||
|
asset.asset_type_id AS "typeId",
|
||||||
|
asset.operational_area_id AS "areaId",
|
||||||
|
asset.operator_company_id AS "companyId"
|
||||||
|
FROM assets asset
|
||||||
|
WHERE asset.id = $1::uuid
|
||||||
|
AND asset.information_status <> 'INACTIVE'
|
||||||
|
`, [parentId])) as Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
typeId: string;
|
||||||
|
areaId: string | null;
|
||||||
|
companyId: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'FIELD_INVENTORY_PARENT_NOT_FOUND',
|
||||||
|
message: 'La ubicación padre no existe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
parent.id !== context.areaId
|
||||||
|
&& (parent.areaId !== context.areaId || parent.companyId !== context.companyId)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
|
||||||
|
message: 'La ubicación padre no pertenece al Área y Operadora de la inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
|
||||||
|
const [asset] = (await this.dataSource.query(`
|
||||||
|
SELECT id,
|
||||||
|
operational_area_id AS "areaId",
|
||||||
|
operator_company_id AS "companyId"
|
||||||
|
FROM assets
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
AND information_status <> 'INACTIVE'
|
||||||
|
`, [assetId])) as Array<{
|
||||||
|
id: string;
|
||||||
|
areaId: string | null;
|
||||||
|
companyId: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'FIELD_INVENTORY_NOT_FOUND',
|
||||||
|
message: 'Registro de Inventario no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
|
||||||
|
message: 'El registro no pertenece al Área y Operadora de esta inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isSelected(visitId: string, assetId: string): Promise<boolean> {
|
||||||
|
const rows = await this.dataSource.query(`
|
||||||
|
SELECT 1
|
||||||
|
FROM inspection_visit_assets
|
||||||
|
WHERE visit_id = $1::uuid
|
||||||
|
AND asset_id = $2::uuid
|
||||||
|
AND included = true
|
||||||
|
`, [visitId, assetId]) as unknown[];
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async captureStatus(
|
||||||
|
visitId: string,
|
||||||
|
assetId: string,
|
||||||
|
): Promise<FieldInventoryCaptureStatus> {
|
||||||
|
const [row] = (await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM asset_field_discoveries discovery
|
||||||
|
WHERE discovery.visit_id = $1::uuid
|
||||||
|
AND discovery.asset_id = $2::uuid
|
||||||
|
) AS "captureRequired",
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = $2::uuid
|
||||||
|
) AS "hasGeometry",
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM asset_field_capture_events event
|
||||||
|
WHERE event.visit_id = $1::uuid
|
||||||
|
AND event.asset_id = $2::uuid
|
||||||
|
AND event.event_type = 'CREATED'
|
||||||
|
) AS "creationGpsCaptured",
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM asset_field_capture_events event
|
||||||
|
WHERE event.visit_id = $1::uuid
|
||||||
|
AND event.asset_id = $2::uuid
|
||||||
|
AND event.event_type = 'PHOTO'
|
||||||
|
) AS "fieldPhotoCount",
|
||||||
|
(
|
||||||
|
SELECT JSONB_BUILD_OBJECT(
|
||||||
|
'latitude', event.device_latitude,
|
||||||
|
'longitude', event.device_longitude,
|
||||||
|
'accuracyM', event.device_accuracy_m,
|
||||||
|
'capturedAt', event.device_captured_at,
|
||||||
|
'deviceLabel', event.device_label
|
||||||
|
)
|
||||||
|
FROM asset_field_capture_events event
|
||||||
|
WHERE event.visit_id = $1::uuid
|
||||||
|
AND event.asset_id = $2::uuid
|
||||||
|
ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS "latestDeviceCapture",
|
||||||
|
(
|
||||||
|
SELECT JSONB_BUILD_OBJECT(
|
||||||
|
'latitude', event.exif_latitude,
|
||||||
|
'longitude', event.exif_longitude,
|
||||||
|
'capturedAt', event.exif_captured_at
|
||||||
|
)
|
||||||
|
FROM asset_field_capture_events event
|
||||||
|
WHERE event.visit_id = $1::uuid
|
||||||
|
AND event.asset_id = $2::uuid
|
||||||
|
AND event.event_type = 'PHOTO'
|
||||||
|
ORDER BY event.device_captured_at DESC, event.created_at DESC, event.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS "latestPhotoExif"
|
||||||
|
`, [visitId, assetId])) as Array<{
|
||||||
|
captureRequired: boolean;
|
||||||
|
hasGeometry: boolean;
|
||||||
|
creationGpsCaptured: boolean;
|
||||||
|
fieldPhotoCount: number;
|
||||||
|
latestDeviceCapture: FieldInventoryCaptureStatus['latestDeviceCapture'];
|
||||||
|
latestPhotoExif: FieldInventoryCaptureStatus['latestPhotoExif'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const captureRequired = Boolean(row?.captureRequired);
|
||||||
|
const hasGeometry = Boolean(row?.hasGeometry);
|
||||||
|
const creationGpsCaptured = Boolean(row?.creationGpsCaptured);
|
||||||
|
const fieldPhotoCount = Number(row?.fieldPhotoCount ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
captureRequired,
|
||||||
|
hasGeometry,
|
||||||
|
creationGpsCaptured,
|
||||||
|
fieldPhotoCount,
|
||||||
|
readyForFinding: !captureRequired || (
|
||||||
|
hasGeometry && creationGpsCaptured && fieldPhotoCount > 0
|
||||||
|
),
|
||||||
|
latestDeviceCapture: row?.latestDeviceCapture ?? null,
|
||||||
|
latestPhotoExif: row?.latestPhotoExif ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextFieldCode(observedAt: Date): Promise<string> {
|
||||||
|
const year = observedAt.getUTCFullYear();
|
||||||
|
if (!Number.isInteger(year) || year < 2000 || year > 9999) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FIELD_INVENTORY_INVALID_CAPTURE_DATE',
|
||||||
|
message: 'La fecha de captura no es válida para generar el código',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = (await this.dataSource.query(`
|
||||||
|
INSERT INTO field_inventory_code_sequences (year, last_value)
|
||||||
|
VALUES ($1, 1)
|
||||||
|
ON CONFLICT (year)
|
||||||
|
DO UPDATE SET last_value = field_inventory_code_sequences.last_value + 1
|
||||||
|
RETURNING last_value AS value
|
||||||
|
`, [year])) as Array<{ value: number }>;
|
||||||
|
|
||||||
|
const value = Number(row?.value ?? 0);
|
||||||
|
if (!value) throw new Error('No se pudo generar el código de Inventario de campo');
|
||||||
|
return `CAM-${year}-${String(value).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
|||||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
import { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
|
import { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
|
||||||
|
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
|
||||||
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
||||||
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
|
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
|
||||||
import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
||||||
@@ -154,4 +155,15 @@ export class InspectionVisitsController {
|
|||||||
) {
|
) {
|
||||||
return this.visits.start(id, principal, request);
|
return this.visits.start(id, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/close')
|
||||||
|
@RequirePermissions('inspections.execute')
|
||||||
|
close(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: CloseInspectionVisitDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.visits.close(id, dto, principal, request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssetMasterModule } from '../asset-master/asset-master.module';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { InspectionFindingsModule } from '../inspection-findings/inspection-findings.module';
|
||||||
|
import { F3FieldInventoryStructureService } from './f3-field-inventory-structure.service';
|
||||||
|
import { FieldFindingsController } from './field-findings.controller';
|
||||||
|
import { FieldFindingsService } from './field-findings.service';
|
||||||
|
import { FieldInventoryController } from './field-inventory.controller';
|
||||||
|
import { FieldInventoryService } from './field-inventory.service';
|
||||||
import { InspectionVisitsController } from './inspection-visits.controller';
|
import { InspectionVisitsController } from './inspection-visits.controller';
|
||||||
import { InspectionVisitsService } from './inspection-visits.service';
|
import { InspectionVisitsService } from './inspection-visits.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule],
|
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
|
||||||
controllers: [InspectionVisitsController],
|
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
|
||||||
providers: [InspectionVisitsService],
|
providers: [
|
||||||
|
InspectionVisitsService,
|
||||||
|
FieldInventoryService,
|
||||||
|
F3FieldInventoryStructureService,
|
||||||
|
FieldFindingsService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class InspectionVisitsModule {}
|
export class InspectionVisitsModule {}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
InspectionVisitStatus,
|
InspectionVisitStatus,
|
||||||
} from '../database/entities';
|
} from '../database/entities';
|
||||||
import type { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
|
import type { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
|
||||||
|
import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
|
||||||
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
||||||
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
||||||
import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
|
import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
|
||||||
@@ -810,6 +811,107 @@ export class InspectionVisitsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async close(
|
||||||
|
id: string,
|
||||||
|
dto: CloseInspectionVisitDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<InspectionVisitView> {
|
||||||
|
assertMobileInspector(principal);
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const visit = await this.lockVisit(manager, id);
|
||||||
|
if (visit.status !== InspectionVisitStatus.IN_PROGRESS) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
|
||||||
|
message: 'La inspección debe estar en curso para cerrarse',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!principal.permissions.includes('inspections.manage')) {
|
||||||
|
const memberIds = await this.activeMemberIds(manager, id);
|
||||||
|
if (!memberIds.includes(principal.userId)) {
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
|
||||||
|
message: 'La inspección no está asignada al usuario actual',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [acts] = (await manager.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE act.status = 'READY'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM inspection_act_signatures signature
|
||||||
|
WHERE signature.act_id = act.id
|
||||||
|
AND signature.signer_type = 'INSPECTOR'
|
||||||
|
AND signature.status = 'SIGNED'
|
||||||
|
)
|
||||||
|
)::integer AS "readyWithoutInspector",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE act.status = 'READY'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM inspection_act_signatures signature
|
||||||
|
WHERE signature.act_id = act.id
|
||||||
|
AND signature.signer_type = 'COMPANY_RESPONSIBLE'
|
||||||
|
)
|
||||||
|
)::integer AS "pendingCompany"
|
||||||
|
FROM inspection_acts act
|
||||||
|
WHERE act.visit_id = $1
|
||||||
|
AND act.status <> 'CANCELLED'
|
||||||
|
`, [id])) as Array<{ drafts: number; readyWithoutInspector: number; pendingCompany: number }>;
|
||||||
|
if (Number(acts?.drafts ?? 0) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING',
|
||||||
|
message: 'Prepará o cancelá las actas en borrador antes de cerrar la inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Number(acts?.readyWithoutInspector ?? 0) > 0) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING',
|
||||||
|
message: 'Toda acta preparada debe tener firma de inspector antes de cerrar la inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const serverClosedAt = new Date();
|
||||||
|
const clientClosedAt = new Date(dto.clientClosedAt);
|
||||||
|
if (visit.actualStartedAt && clientClosedAt.getTime() < visit.actualStartedAt.getTime()) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME',
|
||||||
|
message: 'La fecha de cierre del dispositivo no puede ser anterior al inicio de la inspección',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INSPECTION_VISIT_INVALID_DEVICE_TIME',
|
||||||
|
message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
visit.status = InspectionVisitStatus.CLOSED;
|
||||||
|
visit.actualClosedAt = serverClosedAt;
|
||||||
|
visit.updatedBy = principal.userId;
|
||||||
|
await manager.getRepository(InspectionVisit).save(visit);
|
||||||
|
const updated = await this.loadView(manager, id);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED,
|
||||||
|
entityType: 'inspection_visit',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: { status: InspectionVisitStatus.IN_PROGRESS },
|
||||||
|
afterData: {
|
||||||
|
status: InspectionVisitStatus.CLOSED,
|
||||||
|
clientClosedAt: clientClosedAt.toISOString(),
|
||||||
|
serverClosedAt: serverClosedAt.toISOString(),
|
||||||
|
pendingCompanySignatures: Number(acts?.pendingCompany ?? 0),
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
closeSource: 'ANDROID',
|
||||||
|
actsMayCompleteCompanySignatureLater: true,
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private visitSelect(where: string): string {
|
private visitSelect(where: string): string {
|
||||||
return `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user