Compare commits
237
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b89fd7c4d6 | ||
|
|
4dce65d577 | ||
|
|
5c78aee5e9 | ||
|
|
a985fba96a | ||
|
|
d690040a10 | ||
|
|
35d4630581 | ||
|
|
1dc3282055 | ||
|
|
7875fea3ca | ||
|
|
0d5bdd25ce | ||
|
|
a75e9bdcb0 | ||
|
|
3027e9a8c8 | ||
|
|
09a190532c | ||
|
|
8a8ab47453 | ||
|
|
9b86f49c9e | ||
|
|
8584249eb2 | ||
|
|
5dc7ea037d | ||
|
|
969ffddc76 | ||
|
|
4c47916bd1 | ||
|
|
2121df0b55 | ||
|
|
fcd64d5f43 | ||
|
|
844c1a950f | ||
|
|
e1e44e6352 | ||
|
|
94a8d5c92f | ||
|
|
3bfa3bce1b | ||
|
|
4df8f40dd4 | ||
|
|
7b6460dff2 | ||
|
|
30353c338b | ||
|
|
95a41f39d7 | ||
|
|
100d457484 | ||
|
|
4d0ed8b046 | ||
|
|
a510d309bc | ||
|
|
9606168ac6 | ||
|
|
0442511065 | ||
|
|
27d14d2d4e | ||
|
|
fa64ba7727 | ||
|
|
d0b805b281 | ||
|
|
b3125b51ad | ||
|
|
b67db745e4 | ||
|
|
f0ffde975b | ||
|
|
d8b02bfaf1 | ||
|
|
7a406df1ad | ||
|
|
8c89cd05c9 | ||
|
|
f7a4e7f228 | ||
|
|
7b9d2b7903 | ||
|
|
53db8b93bb | ||
|
|
61f77505df | ||
|
|
c28e5b060d | ||
|
|
2c1fdf7fb2 | ||
|
|
afb6feb4c1 | ||
|
|
7174d379e5 | ||
|
|
ff7a99725e | ||
|
|
6f100bf9ba | ||
|
|
2968f83b36 | ||
|
|
cac3161cbb | ||
|
|
97296ba37b | ||
|
|
80275969c9 | ||
|
|
345ef76820 | ||
|
|
40e33f8312 | ||
|
|
57739dcb3e | ||
|
|
271290abe4 | ||
|
|
5393a40e8b | ||
|
|
3a1379d49a | ||
|
|
ad44dc7ef6 | ||
|
|
248fb6285b | ||
|
|
792e21e669 | ||
|
|
9fab773090 | ||
|
|
38742bf140 | ||
|
|
2018e1aeba | ||
|
|
c11808f560 | ||
|
|
28ec002710 | ||
|
|
90851e6e7d | ||
|
|
0e2b524a26 | ||
|
|
5bd392dcb4 | ||
|
|
232614ccf0 | ||
|
|
b24c3787c9 | ||
|
|
71d0884557 | ||
|
|
2506bece76 | ||
|
|
a9fc9f3e8e | ||
|
|
f70962a819 | ||
|
|
e6ae2807bc | ||
|
|
5404409042 | ||
|
|
49b321ca87 | ||
|
|
048b7b90af | ||
|
|
c53997a41c | ||
|
|
459ad03326 | ||
|
|
a816f4cfdf | ||
|
|
98545820ac | ||
|
|
a9b2ed4b4b | ||
|
|
5386ccf9b5 | ||
|
|
0df7935b54 | ||
|
|
2d2a268220 | ||
|
|
b021189a94 | ||
|
|
ce0bbf1d94 | ||
|
|
3f0a1e5441 | ||
|
|
bd767b7e32 | ||
|
|
d08162fca8 | ||
|
|
2826960d71 | ||
|
|
0718414fdb | ||
|
|
a4a57d3109 | ||
|
|
e3b24cba3d | ||
|
|
cc8c5d9392 | ||
|
|
27f52a05c8 | ||
|
|
b5b6942e39 | ||
|
|
0609b7faab | ||
|
|
0aa4e06d17 | ||
|
|
d2ae9f9bd4 | ||
|
|
e3c347dbb8 | ||
|
|
116dd24b7b | ||
|
|
c2c935fd85 | ||
|
|
068cf8ae3a | ||
|
|
fbf426ed90 | ||
|
|
46c2abb4d1 | ||
|
|
50728e83ba | ||
|
|
cbf8f0c738 | ||
|
|
e2049b084f | ||
|
|
73eb6fc192 | ||
|
|
89f48dd82d | ||
|
|
5b22dde1c6 | ||
|
|
3d3d89a76a | ||
|
|
29864fb2ae | ||
|
|
dea007581c | ||
|
|
de87952743 | ||
|
|
0bab3e5b70 | ||
|
|
17e2e1902b | ||
|
|
3e2b021943 | ||
|
|
830addb7b5 | ||
|
|
2319d06685 | ||
|
|
8d962e783d | ||
|
|
b69b773094 | ||
|
|
5996c3819a | ||
|
|
7ac42fa05d | ||
|
|
3180101ef7 | ||
|
|
572c8a4af1 | ||
|
|
91480779dd | ||
|
|
0a6500fbff | ||
|
|
69bc720899 | ||
|
|
ce33b32820 | ||
|
|
212f4dcee5 | ||
|
|
1775392eb1 | ||
|
|
92cf0dee97 | ||
|
|
2e5c4c35aa | ||
|
|
1192ed2cdd | ||
|
|
4db3797afc | ||
|
|
7a910d7505 | ||
|
|
c999354645 | ||
|
|
bc7cd48436 | ||
|
|
44eccaa36c | ||
|
|
82d8af2728 | ||
|
|
408fd5990d | ||
|
|
c715029dbc | ||
|
|
f83ab824ff | ||
|
|
513fe6121b | ||
|
|
fb12ce3cff | ||
|
|
558fdbc85d | ||
|
|
4a86dd15d9 | ||
|
|
86e692c537 | ||
|
|
f8e866a140 | ||
|
|
30f543ad08 | ||
|
|
747ad21262 | ||
|
|
a9a6b2fa1f | ||
|
|
4caf6216de | ||
|
|
f20cc28045 | ||
|
|
3b4310b63a | ||
|
|
335d327984 | ||
|
|
f4e5480ac8 | ||
|
|
bdcf193a90 | ||
|
|
e577e4c976 | ||
|
|
343dbe086f | ||
|
|
a178f64763 | ||
|
|
badb79be5b | ||
|
|
161f4a9252 | ||
|
|
73c69c7a62 | ||
|
|
ef084dd899 | ||
|
|
eeb35da02c | ||
|
|
0db4567289 | ||
|
|
53702009e3 | ||
|
|
f5ab6ce885 | ||
|
|
1bed1cae98 | ||
|
|
d9698f11f0 | ||
|
|
d0c55b8b39 | ||
|
|
009f4d10cf | ||
|
|
c30f167731 | ||
|
|
8b033dc297 | ||
|
|
236605a7d7 | ||
|
|
af6256ce0f | ||
|
|
0a8cb8f8e5 | ||
|
|
5e14ffda17 | ||
|
|
fcf52c60ff | ||
|
|
577e267f49 | ||
|
|
83ab2d4060 | ||
|
|
2b63638c35 | ||
|
|
c3c798baed | ||
|
|
a836c7535a | ||
|
|
88179eb67c | ||
|
|
4d5c83732e | ||
|
|
0833746e1d | ||
|
|
e02a3d9b58 | ||
|
|
cbe69a8fff | ||
|
|
8b4cc99e53 | ||
|
|
5c1c046be6 | ||
|
|
e25f72ac0a | ||
|
|
5e6631554a | ||
|
|
9a79b7eb86 | ||
|
|
d071e6be74 | ||
|
|
2d97398c01 | ||
|
|
489fb309b3 | ||
|
|
a7f53f32f0 | ||
|
|
ce3d3cf8b7 | ||
|
|
96093d9ed4 | ||
|
|
a1d994b6d4 | ||
|
|
39846c81a4 | ||
|
|
31256ea0b5 | ||
|
|
ad66bb3e61 | ||
|
|
0c35e801eb | ||
|
|
b8fde4c32d | ||
|
|
416aac335c | ||
|
|
97a6fb029d | ||
|
|
f23a28e438 | ||
|
|
c348f3162c | ||
|
|
56f9c4234b | ||
|
|
e7a9ff7dd9 | ||
|
|
01efa2a8cc | ||
|
|
bda4ce224d | ||
|
|
92240b21c2 | ||
|
|
6cc687d5e1 | ||
|
|
c2d4f82a3a | ||
|
|
f184c73c9e | ||
|
|
d834bc7b82 | ||
|
|
c97e5ae33c | ||
|
|
0f1164fae8 | ||
|
|
ab665e51b5 | ||
|
|
13f2dcfa95 | ||
|
|
2fb48ec41e | ||
|
|
52a068c817 | ||
|
|
a9efd71367 | ||
|
|
7a41ce25ad | ||
|
|
48bbe293d9 |
+11
-1
@@ -21,10 +21,20 @@ ACCESS_COOKIE_NAME=dhv2_access
|
|||||||
REFRESH_COOKIE_NAME=dhv2_refresh
|
REFRESH_COOKIE_NAME=dhv2_refresh
|
||||||
CSRF_COOKIE_NAME=dhv2_csrf
|
CSRF_COOKIE_NAME=dhv2_csrf
|
||||||
|
|
||||||
|
INSPECTION_SIGNATURE_ROOT=/app/storage/asset-media/inspection-signatures
|
||||||
INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
|
INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
|
||||||
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
|
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
|
||||||
|
|
||||||
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf
|
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf
|
||||||
|
|
||||||
|
# Enlace público de un solo uso para firma/manifestación de empresa.
|
||||||
|
# En producción: https://dhv2.korexlabs.com/firma-acta
|
||||||
|
# En entorno de prueba: https://prueba.dhv2.korexlabs.com/firma-acta
|
||||||
|
COMPANY_SIGNATURE_PUBLIC_BASE_URL=https://dhv2.korexlabs.com/firma-acta
|
||||||
|
|
||||||
|
# Clave maestra de 32 bytes (64 hex o base64) para cifrar la contraseña SMTP guardada por Superadmin.
|
||||||
|
SMTP_SETTINGS_MASTER_KEY=CHANGE_ME_WITH_32_RANDOM_BYTES
|
||||||
|
|
||||||
|
# Fallback SMTP. Se usa sólo hasta que Superadmin guarde una configuración en la base de datos.
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
name: Android APK
|
name: Android APK
|
||||||
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
|
# F5: genera una APK debug verificable contra la API productiva F5.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
- 'main'
|
||||||
|
- 'release/f5-android-test'
|
||||||
- 'feature/f2-2*'
|
- 'feature/f2-2*'
|
||||||
- 'feature/f2-3*'
|
- 'feature/f2-3*'
|
||||||
- 'feature/f2-4*'
|
- 'feature/f2-4*'
|
||||||
@@ -16,6 +18,8 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'android-app/**'
|
- 'android-app/**'
|
||||||
- 'api-v3/src/auth/**'
|
- 'api-v3/src/auth/**'
|
||||||
|
- 'api-v3/src/inspection-visits/**'
|
||||||
|
- 'api-v3/src/inspection-acts/**'
|
||||||
- '.github/workflows/android.yml'
|
- '.github/workflows/android.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -58,7 +62,7 @@ jobs:
|
|||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: DH-Inspeccion-F3.2-0.12.0-debug
|
name: DH-Inspeccion-F5-0.13.0-debug
|
||||||
path: android-app/app/build/outputs/apk/debug/app-debug.apk
|
path: android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
retention-days: 30
|
||||||
|
|||||||
@@ -62,8 +62,159 @@ jobs:
|
|||||||
while IFS= read -r -d '' script; do
|
while IFS= read -r -d '' script; do
|
||||||
bash -n "$script"
|
bash -n "$script"
|
||||||
done < <(find scripts -type f -name '*.sh' -print0)
|
done < <(find scripts -type f -name '*.sh' -print0)
|
||||||
|
- name: Validate deploy preflight parity
|
||||||
|
run: |
|
||||||
|
grep -Fq -- '$STAGE/docker-compose.yml:/docker-compose.yml:ro' scripts/deploy-github.sh
|
||||||
|
grep -Fq -- '$STAGE/web-v2:/web-v2:ro' scripts/deploy-github.sh
|
||||||
|
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
|
||||||
- name: Validate Compose
|
- name: Validate Compose
|
||||||
run: docker compose --env-file .env.example config >/dev/null
|
run: docker compose --env-file .env.example config >/dev/null
|
||||||
|
- name: Rehearse migrations on clean PostGIS
|
||||||
|
run: |
|
||||||
|
set -Eeuo pipefail
|
||||||
|
cleanup() {
|
||||||
|
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
cleanup
|
||||||
|
|
||||||
|
docker compose --env-file .env.example up -d db
|
||||||
|
|
||||||
|
# The historical production reset is a one-shot operational migration,
|
||||||
|
# not a bootstrap migration: it requires production data/configuration
|
||||||
|
# that cannot exist at its timestamp in a database rebuilt from zero.
|
||||||
|
# Prove the clean chain reaches that exact guard, then mark only that
|
||||||
|
# one-shot migration as already applied and continue the reproducible
|
||||||
|
# schema chain. The historical migration itself remains untouched.
|
||||||
|
bootstrap_log="$(mktemp)"
|
||||||
|
set +e
|
||||||
|
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
|
||||||
|
bootstrap_status=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
if [ "$bootstrap_status" -eq 0 ]; then
|
||||||
|
echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
|
||||||
|
echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
rm -f "$bootstrap_log"
|
||||||
|
|
||||||
|
docker compose --env-file .env.example exec -T db \
|
||||||
|
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
reset_rows integer;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO reset_rows
|
||||||
|
FROM typeorm_migrations
|
||||||
|
WHERE name = 'ResetProductionOperationalData1788652800000';
|
||||||
|
|
||||||
|
IF reset_rows <> 0 THEN
|
||||||
|
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO typeorm_migrations ("timestamp", name)
|
||||||
|
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
docker compose --env-file .env.example --profile tools run --rm migrate
|
||||||
|
|
||||||
|
docker compose --env-file .env.example exec -T db \
|
||||||
|
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
f5_migrations integer;
|
||||||
|
real_inventory integer;
|
||||||
|
source_areas integer;
|
||||||
|
source_yacimientos integer;
|
||||||
|
source_installations integer;
|
||||||
|
source_subinstallations integer;
|
||||||
|
source_findings integer;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO f5_migrations
|
||||||
|
FROM typeorm_migrations
|
||||||
|
WHERE name IN (
|
||||||
|
'F5InventoryPhysicalInstance1790087100000',
|
||||||
|
'F5CanonicalInventoryHierarchy1790087150000',
|
||||||
|
'F5AuthoritativeTerritory1790087200000',
|
||||||
|
'F5OperationalContextCompatibility1790087250000',
|
||||||
|
'F5AuthoritativeInventoryCatalog1790087300000'
|
||||||
|
);
|
||||||
|
IF f5_migrations <> 5 THEN
|
||||||
|
RAISE EXCEPTION 'Expected 5 F5 migrations, got %', f5_migrations;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO real_inventory
|
||||||
|
FROM assets WHERE is_inventory_instance=true AND information_status<>'INACTIVE';
|
||||||
|
IF real_inventory <> 0 THEN
|
||||||
|
RAISE EXCEPTION 'Fresh F5 database must start with 0 real Inventory instances, got %', real_inventory;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA'),
|
||||||
|
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')
|
||||||
|
INTO source_areas,source_yacimientos
|
||||||
|
FROM source_documents document
|
||||||
|
JOIN asset_source_documents link ON link.document_id=document.id
|
||||||
|
JOIN assets asset ON asset.id=link.asset_id
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE document.document_number='DH-F5-TERRITORY';
|
||||||
|
IF source_areas <> 64 OR source_yacimientos <> 230 THEN
|
||||||
|
RAISE EXCEPTION 'F5 territory preload mismatch: areas %, yacimientos %', source_areas,source_yacimientos;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
|
||||||
|
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
|
||||||
|
INTO source_installations,source_subinstallations
|
||||||
|
FROM inventory_families
|
||||||
|
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
|
||||||
|
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
|
||||||
|
RAISE EXCEPTION 'F5 family preload mismatch: installations %, subinstallations %', source_installations,source_subinstallations;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT COUNT(*) INTO source_findings
|
||||||
|
FROM finding_catalog_items item
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
WHERE lower(category.code)='f5model' AND item.is_active=true;
|
||||||
|
IF source_findings <> 177 THEN
|
||||||
|
RAISE EXCEPTION 'F5 finding preload mismatch: %', source_findings;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Prove the five F5 migrations are actually reversible on a clean state.
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
docker compose --env-file .env.example --profile tools run --rm migrate npm run migration:revert
|
||||||
|
done
|
||||||
|
|
||||||
|
docker compose --env-file .env.example exec -T db \
|
||||||
|
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||||
|
DO $$
|
||||||
|
DECLARE f5_migrations integer; instance_column integer;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO f5_migrations
|
||||||
|
FROM typeorm_migrations
|
||||||
|
WHERE name LIKE 'F5%1790087%';
|
||||||
|
IF f5_migrations <> 0 THEN
|
||||||
|
RAISE EXCEPTION 'F5 rollback left % migration rows behind', f5_migrations;
|
||||||
|
END IF;
|
||||||
|
SELECT COUNT(*) INTO instance_column
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema='public' AND table_name='assets' AND column_name='is_inventory_instance';
|
||||||
|
IF instance_column <> 0 THEN
|
||||||
|
RAISE EXCEPTION 'F5 rollback left is_inventory_instance behind';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Reapply them once more. Each F5 migration performs its own source/cardinality checks.
|
||||||
|
docker compose --env-file .env.example --profile tools run --rm migrate
|
||||||
|
docker compose --env-file .env.example exec -T db \
|
||||||
|
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 -Atc \
|
||||||
|
"SELECT CASE WHEN COUNT(*)=5 THEN 'F5_REAPPLY_OK' ELSE 'F5_REAPPLY_FAILED:'||COUNT(*) END FROM typeorm_migrations WHERE name IN ('F5InventoryPhysicalInstance1790087100000','F5CanonicalInventoryHierarchy1790087150000','F5AuthoritativeTerritory1790087200000','F5OperationalContextCompatibility1790087250000','F5AuthoritativeInventoryCatalog1790087300000');" \
|
||||||
|
| grep -Fx 'F5_REAPPLY_OK'
|
||||||
- name: VPS-equivalent isolated API preflight
|
- name: VPS-equivalent isolated API preflight
|
||||||
run: |
|
run: |
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
@@ -72,6 +223,9 @@ jobs:
|
|||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$PWD/api-v3/test:/app/test:ro" \
|
-v "$PWD/api-v3/test:/app/test:ro" \
|
||||||
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||||
|
-v "$PWD/docker-compose.yml:/docker-compose.yml:ro" \
|
||||||
|
-v "$PWD/web-v2:/web-v2:ro" \
|
||||||
|
-v "$PWD/android-app:/android-app:ro" \
|
||||||
"$image" npm test
|
"$image" npm test
|
||||||
docker image rm "$image" >/dev/null 2>&1 || true
|
docker image rm "$image" >/dev/null 2>&1 || true
|
||||||
- name: Build production images
|
- name: Build production images
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ android {
|
|||||||
applicationId = "com.korexlabs.dhinspeccion"
|
applicationId = "com.korexlabs.dhinspeccion"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 19
|
versionCode = 20
|
||||||
versionName = "0.12.0"
|
versionName = "0.13.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|||||||
@@ -151,11 +151,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createActForSelectedInventory() {
|
fun createActForSelectedInventory(urgency: String = "NON_URGENT") {
|
||||||
val currentVisit = visit ?: return
|
val currentVisit = visit ?: return
|
||||||
val asset = selectedFieldAsset?.asset
|
val asset = selectedFieldAsset?.asset
|
||||||
if (currentVisit.status != "IN_PROGRESS") {
|
if (currentVisit.status != "IN_PROGRESS") {
|
||||||
error = "La inspección debe estar en curso para crear un Acta."
|
error = "La Inspección debe estar en curso para crear un Acta."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (urgency !in setOf("URGENT", "NON_URGENT")) {
|
||||||
|
error = "Elegí si el Acta es urgente o no urgente."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
@@ -163,15 +167,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (acts.any { it.status == "DRAFT" }) {
|
if (acts.any { it.status == "DRAFT" }) {
|
||||||
error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente."
|
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy {
|
launchBusy {
|
||||||
val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code)
|
val created = actsRepository.create(
|
||||||
|
currentVisit.id,
|
||||||
|
asset.id,
|
||||||
|
currentVisit.code,
|
||||||
|
urgency,
|
||||||
|
)
|
||||||
selectedAct = created
|
selectedAct = created
|
||||||
actClosure = actsRepository.closure(created.id)
|
actClosure = actsRepository.closure(created.id)
|
||||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||||
notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
|
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
|
||||||
|
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
|
||||||
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||||
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
|
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
|
||||||
}
|
}
|
||||||
@@ -196,7 +206,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
launchBusy {
|
launchBusy {
|
||||||
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
||||||
notice = "Inventario agregado a la inspección."
|
notice = "Inventario agregado a la Inspección."
|
||||||
inventory = repository.fieldInventory(visitId, null, null).data
|
inventory = repository.fieldInventory(visitId, null, null).data
|
||||||
val draft = selectedDraftAct()
|
val draft = selectedDraftAct()
|
||||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||||
@@ -222,7 +232,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
) {
|
) {
|
||||||
val visitId = visit?.id ?: return
|
val visitId = visit?.id ?: return
|
||||||
if (visit?.status != "IN_PROGRESS") {
|
if (visit?.status != "IN_PROGRESS") {
|
||||||
error = "La inspección debe estar en curso para dar de alta Inventario."
|
error = "La Inspección debe estar en curso para dar de alta Inventario."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (type.familyRequired && familyId == null) {
|
if (type.familyRequired && familyId == null) {
|
||||||
@@ -449,26 +459,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
absenceReason = reason.trim(),
|
absenceReason = reason.trim(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
notice = "Ausencia del responsable registrada."
|
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun prepareSelectedAct() {
|
fun prepareSelectedAct() {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
launchBusy {
|
launchBusy {
|
||||||
actClosure = actsRepository.prepare(actId)
|
actClosure = actsRepository.lock(actId)
|
||||||
refreshSelectedActInternal(actId)
|
refreshSelectedActInternal(actId)
|
||||||
notice = "Acta preparada. Su contenido quedó congelado para las firmas."
|
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reopenSelectedAct() {
|
fun reopenSelectedAct() {
|
||||||
val actId = selectedAct?.id ?: return
|
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
|
||||||
launchBusy {
|
|
||||||
actClosure = actsRepository.reopen(actId)
|
|
||||||
refreshSelectedActInternal(actId)
|
|
||||||
notice = "Acta reabierta. Podés corregirla antes de volver a preparar."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun signSelectedActAsInspector(
|
fun signSelectedActAsInspector(
|
||||||
@@ -507,13 +512,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
fun recordCompanyOutcome(status: String, reason: String) {
|
fun recordCompanyOutcome(status: String, reason: String) {
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
|
if (status != "REFUSED") {
|
||||||
|
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
|
||||||
|
return
|
||||||
|
}
|
||||||
if (reason.trim().length < 10) {
|
if (reason.trim().length < 10) {
|
||||||
error = "Indicá un motivo de al menos 10 caracteres."
|
error = "Indicá un motivo de al menos 10 caracteres."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
launchBusy {
|
launchBusy {
|
||||||
actClosure = actsRepository.companyOutcome(actId, status, reason)
|
actClosure = actsRepository.companyOutcome(actId, status, reason)
|
||||||
notice = if (status == "ABSENT") "Ausencia de empresa asentada." else "Negativa a firmar asentada."
|
notice = "Negativa a firmar asentada."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,11 +530,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val currentVisit = visit ?: return
|
val currentVisit = visit ?: return
|
||||||
val actId = selectedAct?.id ?: return
|
val actId = selectedAct?.id ?: return
|
||||||
launchBusy {
|
launchBusy {
|
||||||
actClosure = actsRepository.closeAct(actId)
|
actClosure = actsRepository.seal(actId)
|
||||||
refreshSelectedActInternal(actId)
|
refreshSelectedActInternal(actId)
|
||||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||||
clearFindingState()
|
clearFindingState()
|
||||||
notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección."
|
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,7 +543,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
launchBusy {
|
launchBusy {
|
||||||
visit = actsRepository.closeVisit(visitId)
|
visit = actsRepository.closeVisit(visitId)
|
||||||
loadVisitsInternal()
|
loadVisitsInternal()
|
||||||
notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para oficina."
|
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ data class PersonSummary(
|
|||||||
data class VisitSummary(
|
data class VisitSummary(
|
||||||
val id: String,
|
val id: String,
|
||||||
val code: String,
|
val code: String,
|
||||||
val title: String? = null,
|
|
||||||
val objective: String? = null,
|
val objective: String? = null,
|
||||||
val status: String,
|
val status: String,
|
||||||
val scopeAsset: AssetSummary? = null,
|
val scopeAsset: AssetSummary? = null,
|
||||||
@@ -153,7 +152,6 @@ data class ChecklistSummary(
|
|||||||
data class VisitDetail(
|
data class VisitDetail(
|
||||||
val id: String,
|
val id: String,
|
||||||
val code: String,
|
val code: String,
|
||||||
val title: String? = null,
|
|
||||||
val objective: String? = null,
|
val objective: String? = null,
|
||||||
val status: String,
|
val status: String,
|
||||||
val operationalArea: AssetSummary? = null,
|
val operationalArea: AssetSummary? = null,
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ data class MobileActSummary(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
val observations: String? = null,
|
val observations: String? = null,
|
||||||
|
val urgency: String = "NON_URGENT",
|
||||||
|
val deadlineDays: Int? = null,
|
||||||
|
val deadlineDayType: String? = null,
|
||||||
|
val deadlineBasis: String? = null,
|
||||||
|
val deadlineBaseAt: String? = null,
|
||||||
|
val deadlineAt: String? = null,
|
||||||
|
val lockedAt: String? = null,
|
||||||
|
val lockedSha256: String? = null,
|
||||||
|
val sealedAt: String? = null,
|
||||||
val currentVersion: Int = 0,
|
val currentVersion: Int = 0,
|
||||||
val closedAt: String? = null,
|
val closedAt: String? = null,
|
||||||
val closureSha256: String? = null,
|
val closureSha256: String? = null,
|
||||||
@@ -53,6 +62,15 @@ data class MobileActDetail(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
val observations: String? = null,
|
val observations: String? = null,
|
||||||
|
val urgency: String = "NON_URGENT",
|
||||||
|
val deadlineDays: Int? = null,
|
||||||
|
val deadlineDayType: String? = null,
|
||||||
|
val deadlineBasis: String? = null,
|
||||||
|
val deadlineBaseAt: String? = null,
|
||||||
|
val deadlineAt: String? = null,
|
||||||
|
val lockedAt: String? = null,
|
||||||
|
val lockedSha256: String? = null,
|
||||||
|
val sealedAt: String? = null,
|
||||||
val currentVersion: Int = 0,
|
val currentVersion: Int = 0,
|
||||||
val closedAt: String? = null,
|
val closedAt: String? = null,
|
||||||
val closureSha256: String? = null,
|
val closureSha256: String? = null,
|
||||||
@@ -75,6 +93,7 @@ data class MobileActListResponse(
|
|||||||
|
|
||||||
data class CreateMobileActRequest(
|
data class CreateMobileActRequest(
|
||||||
val occurredAt: String,
|
val occurredAt: String,
|
||||||
|
val urgency: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val summary: String,
|
val summary: String,
|
||||||
val observations: String? = null,
|
val observations: String? = null,
|
||||||
@@ -113,6 +132,15 @@ data class MobileActClosureHeader(
|
|||||||
val code: String,
|
val code: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
val visitId: String,
|
val visitId: String,
|
||||||
|
val urgency: String = "NON_URGENT",
|
||||||
|
val deadlineDays: Int? = null,
|
||||||
|
val deadlineDayType: String? = null,
|
||||||
|
val deadlineBasis: String? = null,
|
||||||
|
val deadlineBaseAt: String? = null,
|
||||||
|
val deadlineAt: String? = null,
|
||||||
|
val lockedAt: String? = null,
|
||||||
|
val lockedSha256: String? = null,
|
||||||
|
val sealedAt: String? = null,
|
||||||
val currentVersion: Int = 0,
|
val currentVersion: Int = 0,
|
||||||
val closedAt: String? = null,
|
val closedAt: String? = null,
|
||||||
val closureSha256: String? = null,
|
val closureSha256: String? = null,
|
||||||
@@ -169,7 +197,7 @@ data class MobileCompanyOutcomeRequest(
|
|||||||
val reason: String,
|
val reason: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MobileCloseActRequest(
|
data class MobileSealActRequest(
|
||||||
val clientClosedAt: String = Instant.now().toString(),
|
val clientClosedAt: String = Instant.now().toString(),
|
||||||
val uploadMode: String = "ONLINE",
|
val uploadMode: String = "ONLINE",
|
||||||
)
|
)
|
||||||
@@ -219,14 +247,8 @@ private interface MobileActsApi {
|
|||||||
@Body request: MobileResponsibleRequest,
|
@Body request: MobileResponsibleRequest,
|
||||||
): MobileActClosure
|
): MobileActClosure
|
||||||
|
|
||||||
@POST("inspection-acts/{actId}/ready")
|
@POST("inspection-acts/{actId}/lock")
|
||||||
suspend fun ready(
|
suspend fun lock(
|
||||||
@Header("Authorization") authorization: String,
|
|
||||||
@Path("actId") actId: String,
|
|
||||||
): MobileActClosure
|
|
||||||
|
|
||||||
@POST("inspection-acts/{actId}/reopen")
|
|
||||||
suspend fun reopen(
|
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
@Path("actId") actId: String,
|
@Path("actId") actId: String,
|
||||||
): MobileActClosure
|
): MobileActClosure
|
||||||
@@ -268,11 +290,11 @@ private interface MobileActsApi {
|
|||||||
@Body request: MobileCompanyOutcomeRequest,
|
@Body request: MobileCompanyOutcomeRequest,
|
||||||
): MobileActClosure
|
): MobileActClosure
|
||||||
|
|
||||||
@POST("inspection-acts/{actId}/close")
|
@POST("inspection-acts/{actId}/seal")
|
||||||
suspend fun closeAct(
|
suspend fun sealAct(
|
||||||
@Header("Authorization") authorization: String,
|
@Header("Authorization") authorization: String,
|
||||||
@Path("actId") actId: String,
|
@Path("actId") actId: String,
|
||||||
@Body request: MobileCloseActRequest,
|
@Body request: MobileSealActRequest,
|
||||||
): MobileActClosure
|
): MobileActClosure
|
||||||
|
|
||||||
@POST("inspection-visits/{visitId}/close")
|
@POST("inspection-visits/{visitId}/close")
|
||||||
@@ -305,14 +327,20 @@ class MobileActsRepository(context: Context) {
|
|||||||
api.act("Bearer ${session.accessToken}", actId)
|
api.act("Bearer ${session.accessToken}", actId)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun create(visitId: String, assetId: String, visitCode: String): MobileActDetail = authorized { session ->
|
suspend fun create(
|
||||||
|
visitId: String,
|
||||||
|
assetId: String,
|
||||||
|
visitCode: String,
|
||||||
|
urgency: String = "NON_URGENT",
|
||||||
|
): MobileActDetail = authorized { session ->
|
||||||
api.createAct(
|
api.createAct(
|
||||||
"Bearer ${session.accessToken}",
|
"Bearer ${session.accessToken}",
|
||||||
visitId,
|
visitId,
|
||||||
CreateMobileActRequest(
|
CreateMobileActRequest(
|
||||||
occurredAt = Instant.now().toString(),
|
occurredAt = Instant.now().toString(),
|
||||||
|
urgency = urgency,
|
||||||
title = "Acta de inspección $visitCode",
|
title = "Acta de inspección $visitCode",
|
||||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.",
|
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
|
||||||
assetIds = listOf(assetId),
|
assetIds = listOf(assetId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -335,12 +363,8 @@ class MobileActsRepository(context: Context) {
|
|||||||
api.responsible("Bearer ${session.accessToken}", actId, request)
|
api.responsible("Bearer ${session.accessToken}", actId, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun prepare(actId: String): MobileActClosure = authorized { session ->
|
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
|
||||||
api.ready("Bearer ${session.accessToken}", actId)
|
api.lock("Bearer ${session.accessToken}", actId)
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun reopen(actId: String): MobileActClosure = authorized { session ->
|
|
||||||
api.reopen("Bearer ${session.accessToken}", actId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun signInspector(
|
suspend fun signInspector(
|
||||||
@@ -378,8 +402,8 @@ class MobileActsRepository(context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun closeAct(actId: String): MobileActClosure = authorized { session ->
|
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
|
||||||
api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest())
|
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
|
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ fun MobileActsScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
|
||||||
var attendance by rememberSaveable(selected?.id) {
|
var attendance by rememberSaveable(selected?.id) {
|
||||||
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
|
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
|
||||||
}
|
}
|
||||||
@@ -129,19 +130,20 @@ fun MobileActsScreen(
|
|||||||
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Actas de esta inspección", fontWeight = FontWeight.Bold)
|
Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
|
||||||
if (model.acts.isEmpty()) {
|
if (model.acts.isEmpty()) {
|
||||||
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
|
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
|
||||||
}
|
}
|
||||||
model.acts.forEach { act ->
|
model.acts.forEach { act ->
|
||||||
val active = selected?.id == act.id
|
val active = selected?.id == act.id
|
||||||
|
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { model.selectAct(act.id) },
|
onClick = { model.selectAct(act.id) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
(if (active) "✓ " else "") +
|
(if (active) "✓ " else "") +
|
||||||
"${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
|
"${act.code} · ${actStatusLabel(act.status)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,12 +158,31 @@ fun MobileActsScreen(
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Nueva Acta", fontWeight = FontWeight.Bold)
|
Text("Nueva Acta", fontWeight = FontWeight.Bold)
|
||||||
if (model.acts.any { it.status == "READY" }) {
|
if (model.acts.any { it.status == "LOCKED" }) {
|
||||||
Text(
|
Text(
|
||||||
"Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. Sólo se permite un borrador a la vez.",
|
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { newActUrgency = "NON_URGENT" },
|
||||||
|
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
|
||||||
|
)
|
||||||
|
AssistChip(
|
||||||
|
onClick = { newActUrgency = "URGENT" },
|
||||||
|
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (newActUrgency == "URGENT") {
|
||||||
|
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
|
||||||
|
} else {
|
||||||
|
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
val selectedInventory = model.selectedFieldAsset?.asset
|
val selectedInventory = model.selectedFieldAsset?.asset
|
||||||
if (selectedInventory == null) {
|
if (selectedInventory == null) {
|
||||||
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
|
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
|
||||||
@@ -171,7 +192,7 @@ fun MobileActsScreen(
|
|||||||
} else {
|
} else {
|
||||||
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
|
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.createActForSelectedInventory() },
|
onClick = { model.createActForSelectedInventory(newActUrgency) },
|
||||||
enabled = !model.busy,
|
enabled = !model.busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Crear nueva Acta") }
|
) { Text("Crear nueva Acta") }
|
||||||
@@ -186,9 +207,14 @@ fun MobileActsScreen(
|
|||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Text(actStatusLabel(selected.status))
|
Text(actStatusLabel(selected.status))
|
||||||
|
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
|
||||||
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
|
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
|
||||||
Text(selected.summary, style = MaterialTheme.typography.bodySmall)
|
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) }
|
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
|
||||||
|
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
|
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,31 +260,28 @@ fun MobileActsScreen(
|
|||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.")
|
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
|
||||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
|
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.")
|
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.prepareSelectedAct() },
|
onClick = { model.prepareSelectedAct() },
|
||||||
enabled = !model.busy && closure?.responsible != null,
|
enabled = !model.busy && closure?.responsible != null,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Preparar Acta para firmas") }
|
) { Text("Finalizar y BLOQUEAR Acta") }
|
||||||
}
|
}
|
||||||
|
|
||||||
"READY" -> {
|
"LOCKED" -> {
|
||||||
val signatures = closure?.signatures.orEmpty()
|
val signatures = closure?.signatures.orEmpty()
|
||||||
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
||||||
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
||||||
|
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
|
||||||
|
|
||||||
Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) }
|
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
|
||||||
if (signatures.isEmpty()) {
|
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text("Volver a borrador")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||||
@@ -274,29 +297,21 @@ fun MobileActsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Text("Recepción de la empresa", fontWeight = FontWeight.Bold)
|
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||||
if (companyOutcome != null) {
|
if (companyOutcome != null && companyResolved) {
|
||||||
val detail = when (companyOutcome.status) {
|
val detail = when (companyOutcome.status) {
|
||||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada"
|
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
|
||||||
"REFUSED" -> "Negativa a firmar"
|
"REFUSED" -> "Negativa a firmar"
|
||||||
"ABSENT" -> "Responsable ausente"
|
|
||||||
else -> companyOutcome.status
|
else -> companyOutcome.status
|
||||||
}
|
}
|
||||||
Text("✓ $detail", color = MaterialTheme.colorScheme.primary)
|
Text("✓ $detail", color = MaterialTheme.colorScheme.primary)
|
||||||
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
||||||
Text("El responsable fue registrado como ausente.")
|
Text(
|
||||||
Button(
|
"El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
|
||||||
onClick = {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
model.recordCompanyOutcome(
|
)
|
||||||
"ABSENT",
|
|
||||||
closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = !model.busy && inspectorSigned,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) { Text("Asentar ausencia en el Acta") }
|
|
||||||
} else {
|
} else {
|
||||||
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -333,28 +348,28 @@ fun MobileActsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
if (inspectorSigned && companyOutcome != null) {
|
if (inspectorSigned && companyResolved) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.closeSelectedAct() },
|
onClick = { model.closeSelectedAct() },
|
||||||
enabled = !model.busy,
|
enabled = !model.busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Cerrar Acta definitivamente") }
|
) { Text("SELLAR Acta definitivamente") }
|
||||||
} else if (inspectorSigned) {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.",
|
"Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"CLOSED" -> {
|
"SEALED" -> {
|
||||||
Card(
|
Card(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||||
Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold)
|
Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
|
||||||
Text("El PDF, informe Word y entregas documentales se generan desde este cierre.")
|
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
|
||||||
closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
|
closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,16 +381,19 @@ fun MobileActsScreen(
|
|||||||
|
|
||||||
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
|
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
val drafts = model.acts.count { it.status == "DRAFT" }
|
val activeActs = model.acts.filter { it.status != "CANCELLED" }
|
||||||
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
val pendingActs = activeActs.filter { it.status != "SEALED" }
|
||||||
Text(
|
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
"No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.",
|
if (pendingActs.isEmpty()) {
|
||||||
)
|
Text("Todas las Actas están SELLADAS. La Inspección ya puede cerrarse.")
|
||||||
|
} else {
|
||||||
|
Text("No puede cerrarse todavía. Falta SELLAR: ${pendingActs.joinToString { it.code }}")
|
||||||
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = { model.closeInspection() },
|
onClick = { model.closeInspection() },
|
||||||
enabled = !model.busy && drafts == 0,
|
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Cerrar inspección y salir de la empresa") }
|
) { Text("Cerrar Inspección y salir de la empresa") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -397,8 +415,8 @@ private fun F32ActMessage(model: MainViewModel) {
|
|||||||
|
|
||||||
private fun actStatusLabel(status: String): String = when (status) {
|
private fun actStatusLabel(status: String): String = when (status) {
|
||||||
"DRAFT" -> "Borrador"
|
"DRAFT" -> "Borrador"
|
||||||
"READY" -> "Preparada para firmas"
|
"LOCKED" -> "BLOQUEADA"
|
||||||
"CLOSED" -> "Cerrada"
|
"SEALED" -> "SELLADA"
|
||||||
"CANCELLED" -> "Cancelada"
|
"CANCELLED" -> "Cancelada"
|
||||||
else -> status
|
else -> status
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dhv2-api",
|
"name": "dhv2-api",
|
||||||
"version": "0.25.0-1",
|
"version": "0.26.0-1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class ActAdministrationService {
|
|||||||
COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_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
|
FROM inspection_findings f WHERE f.act_id = ia.id
|
||||||
) fc ON true
|
) fc ON true
|
||||||
WHERE ia.status IN ('CLOSED', 'RECTIFIED')
|
WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
|
||||||
), classified AS (
|
), classified AS (
|
||||||
SELECT *, CASE
|
SELECT *, CASE
|
||||||
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED'
|
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED'
|
||||||
@@ -110,7 +110,7 @@ export class ActAdministrationService {
|
|||||||
const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
|
const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
|
||||||
const act = rows[0];
|
const act = rows[0];
|
||||||
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' });
|
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.' });
|
if (!['SEALED', 'CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_SEALED', message: 'El seguimiento administrativo comienza cuando el Acta está sellada.' });
|
||||||
return act;
|
return act;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export class FieldBriefingService {
|
|||||||
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
|
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) response ON true
|
) response ON true
|
||||||
WHERE act.status IN ('CLOSED', 'RECTIFIED')
|
WHERE act.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
|
||||||
AND source_visit.id <> $1
|
AND source_visit.id <> $1
|
||||||
AND source_visit.operational_area_id = $2::uuid
|
AND source_visit.operational_area_id = $2::uuid
|
||||||
AND source_visit.operator_company_id = $3::uuid
|
AND source_visit.operator_company_id = $3::uuid
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import { HealthController } from './health.controller';
|
|||||||
import { HealthService } from './health.service';
|
import { HealthService } from './health.service';
|
||||||
import { DashboardModule } from './dashboard/dashboard.module';
|
import { DashboardModule } from './dashboard/dashboard.module';
|
||||||
import { AssetMasterModule } from './asset-master/asset-master.module';
|
import { AssetMasterModule } from './asset-master/asset-master.module';
|
||||||
import { SurveyPlanningModule } from './survey-planning/survey-planning.module';
|
|
||||||
import { SurveyExecutionModule } from './survey-execution/survey-execution.module';
|
|
||||||
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
|
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
|
||||||
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
|
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
|
||||||
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
||||||
@@ -69,8 +67,6 @@ function required(config: ConfigService, key: string): string {
|
|||||||
AdministrationModule,
|
AdministrationModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
AssetMasterModule,
|
AssetMasterModule,
|
||||||
SurveyPlanningModule,
|
|
||||||
SurveyExecutionModule,
|
|
||||||
InspectionVisitsModule,
|
InspectionVisitsModule,
|
||||||
InspectionActsModule,
|
InspectionActsModule,
|
||||||
InspectionFindingsModule,
|
InspectionFindingsModule,
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ export interface AssetVersionDetail extends AssetVersionSummary {
|
|||||||
function assetNotFound(): NotFoundException {
|
function assetNotFound(): NotFoundException {
|
||||||
return new NotFoundException({
|
return new NotFoundException({
|
||||||
code: 'ASSET_NOT_FOUND',
|
code: 'ASSET_NOT_FOUND',
|
||||||
message: 'Activo no encontrado',
|
message: 'Inventario no encontrado',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function versionNotFound(): NotFoundException {
|
function versionNotFound(): NotFoundException {
|
||||||
return new NotFoundException({
|
return new NotFoundException({
|
||||||
code: 'ASSET_VERSION_NOT_FOUND',
|
code: 'ASSET_VERSION_NOT_FOUND',
|
||||||
message: 'Versión de activo no encontrada',
|
message: 'Versión de Inventario no encontrada',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export class AssetHistoryService {
|
|||||||
|
|
||||||
const versionNumber = Number(versionRow.current_version);
|
const versionNumber = Number(versionRow.current_version);
|
||||||
if (!Number.isInteger(versionNumber) || versionNumber < 1) {
|
if (!Number.isInteger(versionNumber) || versionNumber < 1) {
|
||||||
throw new Error(`Versión de activo inválida después de incrementar: ${versionRow.current_version}`);
|
throw new Error(`Versión de Inventario inválida después de incrementar: ${versionRow.current_version}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await this.loadCurrentSnapshot(manager, assetId);
|
const snapshot = await this.loadCurrentSnapshot(manager, assetId);
|
||||||
@@ -101,17 +101,8 @@ export class AssetHistoryService {
|
|||||||
asset_id, version_number, change_type, changed_fields, snapshot,
|
asset_id, version_number, change_type, changed_fields, snapshot,
|
||||||
actor_user_id, actor_username, source, request_id
|
actor_user_id, actor_username, source, request_id
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
[
|
[assetId, versionNumber, changeType, changedFields, snapshot,
|
||||||
assetId,
|
principal.userId, principal.username, source, request.requestId],
|
||||||
versionNumber,
|
|
||||||
changeType,
|
|
||||||
changedFields,
|
|
||||||
snapshot,
|
|
||||||
principal.userId,
|
|
||||||
principal.username,
|
|
||||||
source,
|
|
||||||
request.requestId,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
return versionNumber;
|
return versionNumber;
|
||||||
}
|
}
|
||||||
@@ -123,38 +114,21 @@ export class AssetHistoryService {
|
|||||||
parameters.push(value);
|
parameters.push(value);
|
||||||
return `$${parameters.length}`;
|
return `$${parameters.length}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (query.search?.trim()) {
|
if (query.search?.trim()) {
|
||||||
const search = add(`%${query.search.trim()}%`);
|
const search = add(`%${query.search.trim()}%`);
|
||||||
conditions.push(`(
|
conditions.push(`(version.snapshot->>'code' ILIKE ${search} OR version.snapshot->>'name' ILIKE ${search} OR version.actor_username ILIKE ${search})`);
|
||||||
version.snapshot->>'code' ILIKE ${search}
|
|
||||||
OR version.snapshot->>'name' ILIKE ${search}
|
|
||||||
OR version.actor_username ILIKE ${search}
|
|
||||||
)`);
|
|
||||||
}
|
|
||||||
if (query.typeId) {
|
|
||||||
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
|
||||||
}
|
|
||||||
if (query.status) {
|
|
||||||
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
|
||||||
}
|
|
||||||
if (query.changeType) {
|
|
||||||
conditions.push(`version.change_type = ${add(query.changeType)}`);
|
|
||||||
}
|
}
|
||||||
|
if (query.typeId) conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
||||||
|
if (query.status) conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
||||||
|
if (query.changeType) conditions.push(`version.change_type = ${add(query.changeType)}`);
|
||||||
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
|
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
|
||||||
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
|
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
|
||||||
|
|
||||||
return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
|
return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
|
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
|
||||||
await this.requireAsset(assetId);
|
await this.requireAsset(assetId);
|
||||||
return this.listWithConditions(
|
return this.listWithConditions(query.page, query.pageSize, ['version.asset_id = $1'], [assetId]);
|
||||||
query.page,
|
|
||||||
query.pageSize,
|
|
||||||
['version.asset_id = $1'],
|
|
||||||
[assetId],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
|
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
|
||||||
@@ -170,17 +144,9 @@ export class AssetHistoryService {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listWithConditions(
|
private async listWithConditions(page: number, pageSize: number, conditions: string[], parameters: unknown[]) {
|
||||||
page: number,
|
|
||||||
pageSize: number,
|
|
||||||
conditions: string[],
|
|
||||||
parameters: unknown[],
|
|
||||||
) {
|
|
||||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
const [countRow] = (await this.dataSource.query(
|
const [countRow] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, parameters)) as Array<{ total: number }>;
|
||||||
`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`,
|
|
||||||
parameters,
|
|
||||||
)) as Array<{ total: number }>;
|
|
||||||
const total = Number(countRow?.total ?? 0);
|
const total = Number(countRow?.total ?? 0);
|
||||||
const paginated = [...parameters, pageSize, (page - 1) * pageSize];
|
const paginated = [...parameters, pageSize, (page - 1) * pageSize];
|
||||||
const limit = `$${parameters.length + 1}`;
|
const limit = `$${parameters.length + 1}`;
|
||||||
@@ -194,51 +160,29 @@ export class AssetHistoryService {
|
|||||||
LIMIT ${limit} OFFSET ${offset}`,
|
LIMIT ${limit} OFFSET ${offset}`,
|
||||||
paginated,
|
paginated,
|
||||||
)) as AssetVersionSummary[];
|
)) as AssetVersionSummary[];
|
||||||
|
return { data, meta: { page, pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) } };
|
||||||
return {
|
|
||||||
data,
|
|
||||||
meta: {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectSummary(): string {
|
private selectSummary(): string {
|
||||||
return `SELECT
|
return `SELECT
|
||||||
version.id,
|
version.id, version.asset_id AS "assetId",
|
||||||
version.asset_id AS "assetId",
|
version.snapshot->>'code' AS "assetCode", version.snapshot->>'name' AS "assetName",
|
||||||
version.snapshot->>'code' AS "assetCode",
|
version.snapshot #>> '{type,id}' AS "typeId", version.snapshot #>> '{type,name}' AS "typeName",
|
||||||
version.snapshot->>'name' AS "assetName",
|
|
||||||
version.snapshot #>> '{type,id}' AS "typeId",
|
|
||||||
version.snapshot #>> '{type,name}' AS "typeName",
|
|
||||||
version.snapshot->>'informationStatus' AS "informationStatus",
|
version.snapshot->>'informationStatus' AS "informationStatus",
|
||||||
version.snapshot->>'operationalStatus' AS "operationalStatus",
|
version.snapshot->>'operationalStatus' AS "operationalStatus",
|
||||||
version.version_number AS "versionNumber",
|
version.version_number AS "versionNumber", version.change_type AS "changeType",
|
||||||
version.change_type AS "changeType",
|
version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt",
|
||||||
version.changed_fields AS "changedFields",
|
version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername",
|
||||||
version.occurred_at AS "occurredAt",
|
version.source, version.request_id AS "requestId",
|
||||||
version.actor_user_id AS "actorUserId",
|
|
||||||
version.actor_username AS "actorUsername",
|
|
||||||
version.source,
|
|
||||||
version.request_id AS "requestId",
|
|
||||||
(version.version_number = current_asset.current_version) AS "isCurrent"`;
|
(version.version_number = current_asset.current_version) AS "isCurrent"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requireAsset(assetId: string): Promise<void> {
|
private async requireAsset(assetId: string): Promise<void> {
|
||||||
const [row] = (await this.dataSource.query(
|
const [row] = (await this.dataSource.query('SELECT 1 FROM assets WHERE id = $1', [assetId])) as unknown[];
|
||||||
'SELECT 1 FROM assets WHERE id = $1',
|
|
||||||
[assetId],
|
|
||||||
)) as unknown[];
|
|
||||||
if (!row) throw assetNotFound();
|
if (!row) throw assetNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadCurrentSnapshot(
|
private async loadCurrentSnapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
|
||||||
manager: EntityManager,
|
|
||||||
assetId: string,
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
const [row] = (await manager.query(
|
const [row] = (await manager.query(
|
||||||
`SELECT JSONB_BUILD_OBJECT(
|
`SELECT JSONB_BUILD_OBJECT(
|
||||||
'id', asset.id,
|
'id', asset.id,
|
||||||
@@ -246,82 +190,49 @@ export class AssetHistoryService {
|
|||||||
'name', asset.name,
|
'name', asset.name,
|
||||||
'commonName', asset.common_name,
|
'commonName', asset.common_name,
|
||||||
'description', asset.description,
|
'description', asset.description,
|
||||||
'type', JSONB_BUILD_OBJECT(
|
'type', JSONB_BUILD_OBJECT('id', asset_type.id,'code', asset_type.code,'name', asset_type.name,'operationalRole', asset_type.operational_role),
|
||||||
'id', asset_type.id,
|
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', parent.id,'code', parent.code,'name', parent.name) END,
|
||||||
'code', asset_type.code,
|
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operational_area.id,'code', operational_area.code,'name', operational_area.name) END,
|
||||||
'name', asset_type.name,
|
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operator_company.id,'code', operator_company.code,'name', operator_company.name) END,
|
||||||
'operationalRole', asset_type.operational_role
|
|
||||||
),
|
|
||||||
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
||||||
'id', parent.id,
|
|
||||||
'code', parent.code,
|
|
||||||
'name', parent.name
|
|
||||||
) END,
|
|
||||||
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
||||||
'id', operational_area.id,
|
|
||||||
'code', operational_area.code,
|
|
||||||
'name', operational_area.name
|
|
||||||
) END,
|
|
||||||
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
||||||
'id', operator_company.id,
|
|
||||||
'code', operator_company.code,
|
|
||||||
'name', operator_company.name
|
|
||||||
) END,
|
|
||||||
'informationStatus', asset.information_status,
|
'informationStatus', asset.information_status,
|
||||||
'operationalStatus', asset.operational_status,
|
'operationalStatus', asset.operational_status,
|
||||||
|
'currentFunction', (
|
||||||
|
SELECT JSONB_BUILD_OBJECT(
|
||||||
|
'assignmentId', assignment.id,
|
||||||
|
'id', fn.id,
|
||||||
|
'code', fn.code,
|
||||||
|
'name', fn.name,
|
||||||
|
'validFrom', assignment.valid_from,
|
||||||
|
'reason', assignment.change_reason
|
||||||
|
)
|
||||||
|
FROM inventory_function_assignments assignment
|
||||||
|
JOIN inventory_functions fn ON fn.id=assignment.function_id
|
||||||
|
WHERE assignment.asset_id=asset.id AND assignment.valid_until IS NULL
|
||||||
|
ORDER BY assignment.valid_from DESC LIMIT 1
|
||||||
|
),
|
||||||
'attributes', COALESCE((
|
'attributes', COALESCE((
|
||||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||||
'definitionId', definition.id,
|
'definitionId', definition.id,'code', definition.code,'name', definition.name,
|
||||||
'code', definition.code,
|
'dataType', definition.data_type,'isRequired', definition.is_required,
|
||||||
'name', definition.name,
|
'unit', definition.unit,'options', definition.options,'sortOrder', definition.sort_order,'value', value.value
|
||||||
'dataType', definition.data_type,
|
|
||||||
'isRequired', definition.is_required,
|
|
||||||
'unit', definition.unit,
|
|
||||||
'options', definition.options,
|
|
||||||
'sortOrder', definition.sort_order,
|
|
||||||
'value', value.value
|
|
||||||
) ORDER BY definition.sort_order, definition.name)
|
) ORDER BY definition.sort_order, definition.name)
|
||||||
FROM asset_attribute_definitions definition
|
FROM asset_attribute_definitions definition
|
||||||
LEFT JOIN asset_attribute_values value
|
LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id
|
||||||
ON value.definition_id = definition.id
|
WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true
|
||||||
AND value.asset_id = asset.id
|
|
||||||
WHERE definition.asset_type_id = asset.asset_type_id
|
|
||||||
AND definition.is_active = true
|
|
||||||
), '[]'::jsonb),
|
), '[]'::jsonb),
|
||||||
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
'assetId', geometry.asset_id,
|
'assetId', geometry.asset_id,'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,'geometryType', geometry.geometry_type,
|
||||||
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,
|
'source', geometry.source,'accuracyM', geometry.accuracy_m::double precision,'capturedAt', geometry.captured_at,
|
||||||
'geometryType', geometry.geometry_type,
|
'deviceLabel', geometry.device_label,'createdAt', geometry.created_at,'updatedAt', geometry.updated_at,'updatedBy', geometry.updated_by
|
||||||
'source', geometry.source,
|
|
||||||
'accuracyM', geometry.accuracy_m::double precision,
|
|
||||||
'capturedAt', geometry.captured_at,
|
|
||||||
'deviceLabel', geometry.device_label,
|
|
||||||
'createdAt', geometry.created_at,
|
|
||||||
'updatedAt', geometry.updated_at,
|
|
||||||
'updatedBy', geometry.updated_by
|
|
||||||
) END,
|
) END,
|
||||||
'media', COALESCE((
|
'media', COALESCE((
|
||||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||||
'id', media.id,
|
'id', media.id,'kind', media.kind,'originalName', media.original_name,'mimeType', media.mime_type,
|
||||||
'kind', media.kind,
|
'sizeBytes', media.size_bytes,'sha256', media.sha256,'title', media.title,'description', media.description,
|
||||||
'originalName', media.original_name,
|
'capturedAt', media.captured_at,'latitude', media.latitude,'longitude', media.longitude,'accuracyM', media.accuracy_m,
|
||||||
'mimeType', media.mime_type,
|
'source', media.source,'uploadedBy', media.uploaded_by,'createdAt', media.created_at,'updatedAt', media.updated_at
|
||||||
'sizeBytes', media.size_bytes,
|
|
||||||
'sha256', media.sha256,
|
|
||||||
'title', media.title,
|
|
||||||
'description', media.description,
|
|
||||||
'capturedAt', media.captured_at,
|
|
||||||
'latitude', media.latitude,
|
|
||||||
'longitude', media.longitude,
|
|
||||||
'accuracyM', media.accuracy_m,
|
|
||||||
'source', media.source,
|
|
||||||
'uploadedBy', media.uploaded_by,
|
|
||||||
'createdAt', media.created_at,
|
|
||||||
'updatedAt', media.updated_at
|
|
||||||
) ORDER BY media.created_at, media.id)
|
) ORDER BY media.created_at, media.id)
|
||||||
FROM asset_media media
|
FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL
|
||||||
WHERE media.asset_id = asset.id
|
|
||||||
AND media.deleted_at IS NULL
|
|
||||||
), '[]'::jsonb),
|
), '[]'::jsonb),
|
||||||
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
|
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
|
||||||
'organizationMemberships', COALESCE((
|
'organizationMemberships', COALESCE((
|
||||||
@@ -337,22 +248,8 @@ export class AssetHistoryService {
|
|||||||
'legalRights', COALESCE((
|
'legalRights', COALESCE((
|
||||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
|
||||||
),'[]'::jsonb),
|
),'[]'::jsonb),
|
||||||
'provenance', JSONB_BUILD_OBJECT(
|
'provenance', JSONB_BUILD_OBJECT('origin', asset.data_origin,'sourceName', asset.source_name,'sourceReference', asset.source_reference,'observedAt', asset.source_observed_at,'notes', asset.source_notes,'verifiedAt', asset.provenance_verified_at,'verifiedBy', asset.provenance_verified_by,'updatedAt', asset.provenance_updated_at,'updatedBy', asset.provenance_updated_by),
|
||||||
'origin', asset.data_origin,
|
'createdAt', asset.created_at,'updatedAt', asset.updated_at,'createdBy', asset.created_by,'updatedBy', asset.updated_by,'currentVersion', asset.current_version
|
||||||
'sourceName', asset.source_name,
|
|
||||||
'sourceReference', asset.source_reference,
|
|
||||||
'observedAt', asset.source_observed_at,
|
|
||||||
'notes', asset.source_notes,
|
|
||||||
'verifiedAt', asset.provenance_verified_at,
|
|
||||||
'verifiedBy', asset.provenance_verified_by,
|
|
||||||
'updatedAt', asset.provenance_updated_at,
|
|
||||||
'updatedBy', asset.provenance_updated_by
|
|
||||||
),
|
|
||||||
'createdAt', asset.created_at,
|
|
||||||
'updatedAt', asset.updated_at,
|
|
||||||
'createdBy', asset.created_by,
|
|
||||||
'updatedBy', asset.updated_by,
|
|
||||||
'currentVersion', asset.current_version
|
|
||||||
) AS snapshot
|
) AS snapshot
|
||||||
FROM assets asset
|
FROM assets asset
|
||||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
|
|||||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||||
import { InventoryMergeService } from './inventory-merge.service';
|
import { InventoryMergeService } from './inventory-merge.service';
|
||||||
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||||
|
import { InventoryBrowserController } from './inventory-browser.controller';
|
||||||
|
import { InventoryBrowserService } from './inventory-browser.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule],
|
imports: [AuditModule],
|
||||||
@@ -37,6 +39,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
|
|||||||
AssetsController,
|
AssetsController,
|
||||||
InventoryStructureController,
|
InventoryStructureController,
|
||||||
InventoryFamilyCatalogController,
|
InventoryFamilyCatalogController,
|
||||||
|
InventoryBrowserController,
|
||||||
InventoryMergeController,
|
InventoryMergeController,
|
||||||
FieldInventoryMergeController,
|
FieldInventoryMergeController,
|
||||||
AssetGeometriesController,
|
AssetGeometriesController,
|
||||||
@@ -53,6 +56,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
|
|||||||
AssetsService,
|
AssetsService,
|
||||||
InventoryStructureService,
|
InventoryStructureService,
|
||||||
InventoryFamilyCatalogService,
|
InventoryFamilyCatalogService,
|
||||||
|
InventoryBrowserService,
|
||||||
InventoryMergeService,
|
InventoryMergeService,
|
||||||
MergedInventoryDossierService,
|
MergedInventoryDossierService,
|
||||||
AssetGeometriesService,
|
AssetGeometriesService,
|
||||||
|
|||||||
@@ -79,23 +79,15 @@ export class AssetOperationalRelationsService {
|
|||||||
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||||
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
|
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
|
||||||
const data = (await this.dataSource.query(`
|
const data = (await this.dataSource.query(`
|
||||||
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName"
|
SELECT DISTINCT company.id, company.code, company.name,
|
||||||
FROM (
|
company.common_name AS "commonName", company_type.name AS "typeName"
|
||||||
SELECT relation.company_id
|
FROM area_company_relations relation
|
||||||
FROM area_company_relations relation
|
INNER JOIN assets company ON company.id = relation.company_id
|
||||||
WHERE relation.area_id = $1
|
|
||||||
AND relation.relation_role = 'OPERATOR'
|
|
||||||
AND relation.valid_until IS NULL
|
|
||||||
UNION
|
|
||||||
SELECT asset.operator_company_id AS company_id
|
|
||||||
FROM assets asset
|
|
||||||
WHERE asset.operational_area_id = $1
|
|
||||||
AND asset.operator_company_id IS NOT NULL
|
|
||||||
AND asset.information_status <> 'INACTIVE'
|
|
||||||
) linked
|
|
||||||
INNER JOIN assets company ON company.id = linked.company_id
|
|
||||||
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
||||||
WHERE company.information_status <> 'INACTIVE'
|
WHERE relation.area_id = $1
|
||||||
|
AND relation.relation_role = 'OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
AND company.information_status <> 'INACTIVE'
|
||||||
ORDER BY company.name, company.code
|
ORDER BY company.name, company.code
|
||||||
`, [areaId])) as OperationalAssetSummary[];
|
`, [areaId])) as OperationalAssetSummary[];
|
||||||
return { data };
|
return { data };
|
||||||
@@ -104,23 +96,15 @@ export class AssetOperationalRelationsService {
|
|||||||
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||||
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
|
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
|
||||||
const data = (await this.dataSource.query(`
|
const data = (await this.dataSource.query(`
|
||||||
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName"
|
SELECT DISTINCT area.id, area.code, area.name,
|
||||||
FROM (
|
area.common_name AS "commonName", area_type.name AS "typeName"
|
||||||
SELECT relation.area_id
|
FROM area_company_relations relation
|
||||||
FROM area_company_relations relation
|
INNER JOIN assets area ON area.id = relation.area_id
|
||||||
WHERE relation.company_id = $1
|
|
||||||
AND relation.relation_role = 'OPERATOR'
|
|
||||||
AND relation.valid_until IS NULL
|
|
||||||
UNION
|
|
||||||
SELECT asset.operational_area_id AS area_id
|
|
||||||
FROM assets asset
|
|
||||||
WHERE asset.operator_company_id = $1
|
|
||||||
AND asset.operational_area_id IS NOT NULL
|
|
||||||
AND asset.information_status <> 'INACTIVE'
|
|
||||||
) linked
|
|
||||||
INNER JOIN assets area ON area.id = linked.area_id
|
|
||||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||||
WHERE area.information_status <> 'INACTIVE'
|
WHERE relation.company_id = $1
|
||||||
|
AND relation.relation_role = 'OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
AND area.information_status <> 'INACTIVE'
|
||||||
ORDER BY area.name, area.code
|
ORDER BY area.name, area.code
|
||||||
`, [companyId])) as OperationalAssetSummary[];
|
`, [companyId])) as OperationalAssetSummary[];
|
||||||
return { data };
|
return { data };
|
||||||
@@ -157,12 +141,49 @@ export class AssetOperationalRelationsService {
|
|||||||
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
|
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
|
||||||
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
|
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
|
||||||
|
const [currentOperator] = (await manager.query(`
|
||||||
|
SELECT relation.id,company.name AS "companyName"
|
||||||
|
FROM area_company_relations relation
|
||||||
|
JOIN assets company ON company.id=relation.company_id
|
||||||
|
WHERE relation.area_id=$1
|
||||||
|
AND relation.relation_role='OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
AND relation.company_id<>$2
|
||||||
|
ORDER BY relation.valid_from DESC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE OF relation
|
||||||
|
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
|
||||||
|
if (currentOperator) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
|
||||||
|
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [row] = (await manager.query(`
|
const [row] = (await manager.query(`
|
||||||
INSERT INTO area_company_relations (
|
INSERT INTO area_company_relations (
|
||||||
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
|
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
|
||||||
|
|
||||||
|
// Compatibility snapshot only. This does not move or re-parent Inventory.
|
||||||
|
// area_company_relations remains the temporal source of truth.
|
||||||
|
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE assets asset
|
||||||
|
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
|
||||||
|
FROM asset_types type
|
||||||
|
WHERE type.id=asset.asset_type_id
|
||||||
|
AND type.operational_role='GENERIC'
|
||||||
|
AND asset.operational_area_id=$1::uuid
|
||||||
|
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
|
||||||
|
`,[dto.areaId,dto.companyId,principal.userId]);
|
||||||
|
}
|
||||||
|
|
||||||
const created = await this.loadRelation(manager, row.id);
|
const created = await this.loadRelation(manager, row.id);
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
...administrationAuditContext(principal, request),
|
...administrationAuditContext(principal, request),
|
||||||
@@ -170,6 +191,9 @@ export class AssetOperationalRelationsService {
|
|||||||
entityType: 'area_company_relation',
|
entityType: 'area_company_relation',
|
||||||
entityId: row.id,
|
entityId: row.id,
|
||||||
afterData: this.auditView(created),
|
afterData: this.auditView(created),
|
||||||
|
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
|
||||||
|
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
|
||||||
|
: undefined,
|
||||||
}, manager);
|
}, manager);
|
||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
@@ -198,12 +222,9 @@ export class AssetOperationalRelationsService {
|
|||||||
message: 'La relación ya se encuentra finalizada',
|
message: 'La relación ya se encuentra finalizada',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (before.assignedAssetCount > 0) {
|
|
||||||
throw new ConflictException({
|
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
|
||||||
code: 'AREA_COMPANY_RELATION_IN_USE',
|
// Ending an operator relation must never be blocked by existing Inventory.
|
||||||
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
UPDATE area_company_relations
|
UPDATE area_company_relations
|
||||||
SET valid_until = CURRENT_TIMESTAMP,
|
SET valid_until = CURRENT_TIMESTAMP,
|
||||||
@@ -220,6 +241,10 @@ export class AssetOperationalRelationsService {
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
beforeData: this.auditView(before),
|
beforeData: this.auditView(before),
|
||||||
afterData: this.auditView(updated),
|
afterData: this.auditView(updated),
|
||||||
|
metadata: {
|
||||||
|
inventoryHierarchyChanged:false,
|
||||||
|
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
|
||||||
|
},
|
||||||
}, manager);
|
}, manager);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
@@ -330,7 +355,8 @@ export class AssetOperationalRelationsService {
|
|||||||
(relation.valid_until IS NULL) AS active,
|
(relation.valid_until IS NULL) AS active,
|
||||||
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
|
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
|
||||||
WHERE asset.operational_area_id = relation.area_id
|
WHERE asset.operational_area_id = relation.area_id
|
||||||
AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount"
|
AND asset.operator_company_id = relation.company_id
|
||||||
|
AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount"
|
||||||
FROM area_company_relations relation
|
FROM area_company_relations relation
|
||||||
INNER JOIN assets area ON area.id = relation.area_id
|
INNER JOIN assets area ON area.id = relation.area_id
|
||||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ export class AssetsService {
|
|||||||
|
|
||||||
const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([
|
const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([
|
||||||
this.dataSource.query(
|
this.dataSource.query(
|
||||||
`SELECT DISTINCT visit.id, visit.code, visit.title, visit.status,
|
`SELECT DISTINCT visit.id, visit.code, visit.status,
|
||||||
visit.planned_start_at AS "plannedStartAt",
|
visit.planned_start_at AS "plannedStartAt",
|
||||||
visit.actual_started_at AS "actualStartedAt",
|
visit.actual_started_at AS "actualStartedAt",
|
||||||
visit.actual_closed_at AS "actualClosedAt",
|
visit.actual_closed_at AS "actualClosedAt",
|
||||||
@@ -322,7 +322,7 @@ export class AssetsService {
|
|||||||
`SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status,
|
`SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status,
|
||||||
act.occurred_at AS "occurredAt", act.title, act.summary,
|
act.occurred_at AS "occurredAt", act.title, act.summary,
|
||||||
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
|
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
|
||||||
visit.code AS "visitCode", visit.title AS "visitTitle"
|
visit.code AS "visitCode"
|
||||||
FROM inspection_acts act
|
FROM inspection_acts act
|
||||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||||
LEFT JOIN inspection_act_assets act_asset
|
LEFT JOIN inspection_act_assets act_asset
|
||||||
@@ -342,7 +342,7 @@ export class AssetsService {
|
|||||||
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
|
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
|
||||||
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
|
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
|
||||||
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
|
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
|
||||||
visit.id AS "visitId", visit.code AS "visitCode", visit.title AS "visitTitle"
|
visit.id AS "visitId", visit.code AS "visitCode"
|
||||||
FROM inspection_findings finding
|
FROM inspection_findings finding
|
||||||
JOIN inspection_acts act ON act.id = finding.act_id
|
JOIN inspection_acts act ON act.id = finding.act_id
|
||||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||||
@@ -389,7 +389,7 @@ export class AssetsService {
|
|||||||
verification_link.result_recorded_at AS "resultRecordedAt",
|
verification_link.result_recorded_at AS "resultRecordedAt",
|
||||||
verification_link.rescheduled_control_on AS "rescheduledControlOn",
|
verification_link.rescheduled_control_on AS "rescheduledControlOn",
|
||||||
finding.code AS "findingCode", finding.title AS "findingTitle",
|
finding.code AS "findingCode", finding.title AS "findingTitle",
|
||||||
visit.code AS "visitCode", visit.title AS "visitTitle", visit.status AS "visitStatus",
|
visit.code AS "visitCode", visit.status AS "visitStatus",
|
||||||
(SELECT COUNT(*)::integer
|
(SELECT COUNT(*)::integer
|
||||||
FROM inspection_finding_evidence verification_evidence
|
FROM inspection_finding_evidence verification_evidence
|
||||||
WHERE verification_evidence.finding_id = finding.id
|
WHERE verification_evidence.finding_id = finding.id
|
||||||
@@ -484,7 +484,7 @@ export class AssetsService {
|
|||||||
kind: 'INSPECTION',
|
kind: 'INSPECTION',
|
||||||
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
|
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
|
||||||
title: `Inspección ${String(visit.code)}`,
|
title: `Inspección ${String(visit.code)}`,
|
||||||
description: visit.title,
|
description: null,
|
||||||
href: `/inspecciones/${String(visit.id)}`,
|
href: `/inspecciones/${String(visit.id)}`,
|
||||||
meta: { status: visit.status },
|
meta: { status: visit.status },
|
||||||
}));
|
}));
|
||||||
@@ -867,7 +867,7 @@ export class AssetsService {
|
|||||||
private fieldDiscoverySelect(): string {
|
private fieldDiscoverySelect(): string {
|
||||||
return `SELECT discovery.id, discovery.status, discovery.discovery_notes AS "discoveryNotes", discovery.observed_at AS "observedAt", discovery.reviewed_at AS "reviewedAt", discovery.review_notes AS "reviewNotes", discovery.created_at AS "createdAt",
|
return `SELECT discovery.id, discovery.status, discovery.discovery_notes AS "discoveryNotes", discovery.observed_at AS "observedAt", discovery.reviewed_at AS "reviewedAt", discovery.review_notes AS "reviewNotes", discovery.created_at AS "createdAt",
|
||||||
JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'commonName',asset.common_name,'informationStatus',asset.information_status,'typeId',asset.asset_type_id,'typeName',asset_type.name,'parentId',asset.parent_id,'operationalAreaId',asset.operational_area_id,'operatorCompanyId',asset.operator_company_id) AS asset,
|
JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'commonName',asset.common_name,'informationStatus',asset.information_status,'typeId',asset.asset_type_id,'typeName',asset_type.name,'parentId',asset.parent_id,'operationalAreaId',asset.operational_area_id,'operatorCompanyId',asset.operator_company_id) AS asset,
|
||||||
JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'title',visit.title,'status',visit.status) AS visit,
|
JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'status',visit.status) AS visit,
|
||||||
JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) AS creator,
|
JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) AS creator,
|
||||||
CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',reviewer.id,'username',reviewer.username,'firstName',reviewer.first_name,'lastName',reviewer.last_name) END AS reviewer,
|
CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',reviewer.id,'username',reviewer.username,'firstName',reviewer.first_name,'lastName',reviewer.last_name) END AS reviewer,
|
||||||
CASE WHEN matched.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',matched.id,'code',matched.code,'name',matched.name,'commonName',matched.common_name) END AS "matchedAsset"
|
CASE WHEN matched.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',matched.id,'code',matched.code,'name',matched.name,'commonName',matched.common_name) END AS "matchedAsset"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export const INVENTORY_STRUCTURE_KINDS = [
|
export const INVENTORY_STRUCTURE_KINDS = [
|
||||||
|
'EMPRESA',
|
||||||
'AREA',
|
'AREA',
|
||||||
'YACIMIENTO',
|
'YACIMIENTO',
|
||||||
'INSTALACION',
|
'INSTALACION',
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
|
||||||
|
|
||||||
|
export class InventoryBrowserQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
typeId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetInformationStatus)
|
||||||
|
status?: AssetInformationStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(AssetOperationalStatus)
|
||||||
|
operationalStatus?: AssetOperationalStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operationalAreaId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
operatorCompanyId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||||
|
@IsBoolean()
|
||||||
|
needsValidation?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||||
|
@IsBoolean()
|
||||||
|
hasGeometry?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize = 25;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateInventoryFamilyDto {
|
||||||
|
@IsIn(['INSTALLATION', 'SUBINSTALLATION'])
|
||||||
|
level!: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(240)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentFamilyId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@MaxLength(160, { each: true })
|
||||||
|
informationLabels?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateInventoryFamilyDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(240)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
parentFamilyId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(100)
|
||||||
|
@IsString({ each: true })
|
||||||
|
@MaxLength(160, { each: true })
|
||||||
|
informationLabels?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReplaceInventoryFamilyFindingsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(2000)
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
itemIds!: string[];
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
@MaxLength(2000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsISO8601,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateInventoryFunctionDto {
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(120)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(240)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100000)
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateInventoryFunctionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(240)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(100000)
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChangeInventoryFunctionDto {
|
||||||
|
@IsUUID('4')
|
||||||
|
functionId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
effectiveAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
@@ -37,4 +37,9 @@ export class ListAssetTreeQueryDto {
|
|||||||
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
hasGeometry?: boolean;
|
hasGeometry?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||||
|
@IsBoolean()
|
||||||
|
inventoryOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export class ListAssetsQueryDto {
|
|||||||
@IsEnum(AssetInformationStatus)
|
@IsEnum(AssetInformationStatus)
|
||||||
status?: AssetInformationStatus;
|
status?: AssetInformationStatus;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(AssetOperationalStatus)
|
@IsEnum(AssetOperationalStatus)
|
||||||
operationalStatus?: AssetOperationalStatus;
|
operationalStatus?: AssetOperationalStatus;
|
||||||
@@ -54,6 +53,11 @@ export class ListAssetsQueryDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
hasGeometry?: boolean;
|
hasGeometry?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||||
|
@IsBoolean()
|
||||||
|
inventoryOnly?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
|
||||||
|
import { InventoryBrowserService } from './inventory-browser.service';
|
||||||
|
|
||||||
|
@Controller('inventory-browser')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
export class InventoryBrowserController {
|
||||||
|
constructor(private readonly inventoryBrowser: InventoryBrowserService) {}
|
||||||
|
|
||||||
|
@Get('items')
|
||||||
|
items(@Query() query: InventoryBrowserQueryDto) {
|
||||||
|
return this.inventoryBrowser.items(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('areas')
|
||||||
|
areas(@Query() query: InventoryBrowserQueryDto) {
|
||||||
|
return this.inventoryBrowser.areas(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':parentId/children')
|
||||||
|
children(
|
||||||
|
@Param('parentId', new ParseUUIDPipe({ version: '4' })) parentId: string,
|
||||||
|
@Query() query: InventoryBrowserQueryDto,
|
||||||
|
) {
|
||||||
|
return this.inventoryBrowser.children(parentId, query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import type { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
|
||||||
|
|
||||||
|
type ParentContext = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
typeCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InventoryBrowserService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async items(query: InventoryBrowserQueryDto) {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const conditions = [
|
||||||
|
'asset.is_inventory_instance=true',
|
||||||
|
"asset.information_status<>'INACTIVE'",
|
||||||
|
'type.is_active=true',
|
||||||
|
];
|
||||||
|
const add = (value: unknown): string => {
|
||||||
|
params.push(value);
|
||||||
|
return `$${params.length}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.search?.trim()) {
|
||||||
|
const p = add(`%${query.search.trim()}%`);
|
||||||
|
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
|
||||||
|
}
|
||||||
|
if (query.typeId) conditions.push(`asset.asset_type_id=${add(query.typeId)}::uuid`);
|
||||||
|
if (query.status) conditions.push(`asset.information_status=${add(query.status)}::asset_information_status`);
|
||||||
|
if (query.operationalStatus) conditions.push(`asset.operational_status=${add(query.operationalStatus)}::asset_operational_status`);
|
||||||
|
if (query.needsValidation === true) conditions.push("asset.information_status NOT IN ('VALIDATED','INACTIVE')");
|
||||||
|
if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'");
|
||||||
|
if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
|
||||||
|
if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
|
||||||
|
if (query.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::uuid`);
|
||||||
|
if (query.operatorCompanyId) {
|
||||||
|
const company = add(query.operatorCompanyId);
|
||||||
|
conditions.push(`EXISTS (
|
||||||
|
SELECT 1 FROM area_company_relations relation
|
||||||
|
WHERE relation.area_id=asset.operational_area_id
|
||||||
|
AND relation.company_id=${company}::uuid
|
||||||
|
AND relation.relation_role='OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.join(' AND ');
|
||||||
|
const [countRow] = (await this.dataSource.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE ${where}
|
||||||
|
`,params)) as Array<{total:number}>;
|
||||||
|
const total=Number(countRow?.total ?? 0);
|
||||||
|
const offset=(query.page-1)*query.pageSize;
|
||||||
|
params.push(query.pageSize);
|
||||||
|
const limit=`$${params.length}`;
|
||||||
|
params.push(offset);
|
||||||
|
const offsetParam=`$${params.length}`;
|
||||||
|
|
||||||
|
const data=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 parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id',parent.id,'code',parent.code,'name',parent.name
|
||||||
|
) END AS parent,
|
||||||
|
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id',area.id,'code',area.code,'name',area.name
|
||||||
|
) END AS "operationalArea",
|
||||||
|
(
|
||||||
|
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
|
||||||
|
FROM area_company_relations relation
|
||||||
|
JOIN assets company ON company.id=relation.company_id
|
||||||
|
WHERE relation.area_id=asset.operational_area_id
|
||||||
|
AND relation.relation_role='OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
ORDER BY relation.valid_from DESC,relation.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS "operatorCompany",
|
||||||
|
asset.information_status AS "informationStatus",
|
||||||
|
asset.operational_status AS "operationalStatus",
|
||||||
|
0::integer AS "childrenCount",
|
||||||
|
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
|
||||||
|
CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType",
|
||||||
|
(SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount",
|
||||||
|
asset.data_origin AS "dataOrigin",
|
||||||
|
(asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified",
|
||||||
|
asset.current_version AS "currentVersion",
|
||||||
|
asset.updated_at AS "updatedAt"
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
||||||
|
LEFT JOIN assets area ON area.id=asset.operational_area_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT ST_GeometryType(geometry.geometry)::text AS type
|
||||||
|
FROM asset_geometries geometry
|
||||||
|
WHERE geometry.asset_id=asset.id
|
||||||
|
ORDER BY geometry.updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) geometry_type ON true
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY asset.name,asset.code
|
||||||
|
LIMIT ${limit} OFFSET ${offsetParam}
|
||||||
|
`,params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta:{
|
||||||
|
page:query.page,
|
||||||
|
pageSize:query.pageSize,
|
||||||
|
total,
|
||||||
|
totalPages:total===0 ? 0 : Math.ceil(total/query.pageSize),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async areas(query: InventoryBrowserQueryDto) {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const conditions = [
|
||||||
|
"type.operational_role='AREA'",
|
||||||
|
"area.information_status<>'INACTIVE'",
|
||||||
|
'type.is_active=true',
|
||||||
|
];
|
||||||
|
const add = (value: unknown): string => {
|
||||||
|
params.push(value);
|
||||||
|
return `$${params.length}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.search?.trim()) {
|
||||||
|
const p = add(`%${query.search.trim()}%`);
|
||||||
|
conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`);
|
||||||
|
}
|
||||||
|
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
|
||||||
|
if (query.operatorCompanyId) {
|
||||||
|
const p = add(query.operatorCompanyId);
|
||||||
|
conditions.push(`EXISTS (
|
||||||
|
SELECT 1 FROM area_company_relations relation
|
||||||
|
WHERE relation.area_id=area.id
|
||||||
|
AND relation.company_id=${p}::uuid
|
||||||
|
AND relation.relation_role='OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
area.id,area.code,area.name,area.common_name AS "commonName",
|
||||||
|
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||||
|
area.information_status AS "informationStatus",
|
||||||
|
area.operational_status AS "operationalStatus",
|
||||||
|
(
|
||||||
|
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
|
||||||
|
FROM area_company_relations relation
|
||||||
|
JOIN assets company ON company.id=relation.company_id
|
||||||
|
WHERE relation.area_id=area.id
|
||||||
|
AND relation.relation_role='OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
ORDER BY relation.valid_from DESC,relation.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS "currentOperator",
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM assets yacimiento
|
||||||
|
JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
|
||||||
|
WHERE yacimiento.parent_id=area.id
|
||||||
|
AND lower(ytype.code)='yacimiento'
|
||||||
|
AND yacimiento.information_status<>'INACTIVE'
|
||||||
|
) AS "yacimientoCount",
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM assets inventory
|
||||||
|
WHERE inventory.is_inventory_instance=true
|
||||||
|
AND inventory.information_status<>'INACTIVE'
|
||||||
|
AND inventory.operational_area_id=area.id
|
||||||
|
) AS "inventoryCount"
|
||||||
|
FROM assets area
|
||||||
|
JOIN asset_types type ON type.id=area.asset_type_id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY area.name,area.code
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
return { data, meta: { count: data.length } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async children(parentId: string, query: InventoryBrowserQueryDto) {
|
||||||
|
const parent = await this.parent(parentId);
|
||||||
|
const allowedChildType = this.allowedChildType(parent.typeCode);
|
||||||
|
if (!allowedChildType) return { parent, data: [], meta: { count: 0, hasMore: false } };
|
||||||
|
|
||||||
|
const params: unknown[] = [parentId, allowedChildType];
|
||||||
|
const conditions = [
|
||||||
|
'asset.parent_id=$1::uuid',
|
||||||
|
'lower(type.code)=lower($2)',
|
||||||
|
"asset.information_status<>'INACTIVE'",
|
||||||
|
'type.is_active=true',
|
||||||
|
];
|
||||||
|
if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') {
|
||||||
|
conditions.push('asset.is_inventory_instance=true');
|
||||||
|
}
|
||||||
|
if (query.search?.trim()) {
|
||||||
|
params.push(`%${query.search.trim()}%`);
|
||||||
|
const p = `$${params.length}`;
|
||||||
|
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(201);
|
||||||
|
const limit = `$${params.length}`;
|
||||||
|
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 parent_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id',parent_asset.id,'code',parent_asset.code,'name',parent_asset.name
|
||||||
|
) END AS parent,
|
||||||
|
asset.information_status AS "informationStatus",
|
||||||
|
asset.operational_status AS "operationalStatus",
|
||||||
|
asset.is_inventory_instance AS "isInventoryInstance",
|
||||||
|
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id',family.id,'code',family.code,'name',family.name,'level',family.level
|
||||||
|
) END AS "inventoryFamily",
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM assets child
|
||||||
|
WHERE child.parent_id=asset.id
|
||||||
|
AND child.information_status<>'INACTIVE'
|
||||||
|
AND (
|
||||||
|
lower(type.code)='area'
|
||||||
|
OR child.is_inventory_instance=true
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM asset_types child_type
|
||||||
|
WHERE child_type.id=child.asset_type_id AND lower(child_type.code)='yacimiento'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) AS "childrenCount",
|
||||||
|
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
|
||||||
|
asset.updated_at AS "updatedAt"
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
LEFT JOIN assets parent_asset ON parent_asset.id=asset.parent_id
|
||||||
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY asset.name,asset.code
|
||||||
|
LIMIT ${limit}
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
const hasMore = rows.length > 200;
|
||||||
|
const data = hasMore ? rows.slice(0,200) : rows;
|
||||||
|
return { parent, data, meta: { count: data.length, hasMore } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parent(parentId: string): Promise<ParentContext> {
|
||||||
|
const [parent] = await this.dataSource.query(`
|
||||||
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode"
|
||||||
|
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 ParentContext[];
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' });
|
||||||
|
}
|
||||||
|
if (!['area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
|
||||||
|
message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private allowedChildType(typeCode: string): string | null {
|
||||||
|
switch (typeCode.toLowerCase()) {
|
||||||
|
case 'area': return 'yacimiento';
|
||||||
|
case 'yacimiento': return 'instalacion';
|
||||||
|
case 'instalacion': return 'subinstalacion';
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,66 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
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 {
|
||||||
|
CreateInventoryFamilyDto,
|
||||||
|
ReplaceInventoryFamilyFindingsDto,
|
||||||
|
UpdateInventoryFamilyDto,
|
||||||
|
} from './dto/inventory-family-admin.dto';
|
||||||
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||||
|
|
||||||
@Controller('inventory-families')
|
@Controller('inventory-families')
|
||||||
export class InventoryFamilyCatalogController {
|
export class InventoryFamilyCatalogController {
|
||||||
constructor(private readonly families: InventoryFamilyCatalogService) {}
|
constructor(private readonly families: InventoryFamilyCatalogService) {}
|
||||||
|
|
||||||
|
@Get('admin')
|
||||||
|
@RequirePermissions('asset_types.read')
|
||||||
|
admin() {
|
||||||
|
return this.families.listAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateInventoryFamilyDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.families.create(dto,principal,request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':familyId')
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
update(
|
||||||
|
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
|
||||||
|
@Body() dto:UpdateInventoryFamilyDto,
|
||||||
|
@CurrentAuth() principal:AuthPrincipal,
|
||||||
|
@Req() request:RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.families.update(familyId,dto,principal,request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':familyId/findings')
|
||||||
|
@RequirePermissions('finding_catalog.manage')
|
||||||
|
replaceFindings(
|
||||||
|
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
|
||||||
|
@Body() dto:ReplaceInventoryFamilyFindingsDto,
|
||||||
|
@CurrentAuth() principal:AuthPrincipal,
|
||||||
|
@Req() request:RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.families.replaceFindings(familyId,dto,principal,request);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':familyId/findings')
|
@Get(':familyId/findings')
|
||||||
@RequirePermissions('assets.read')
|
@RequirePermissions('assets.read')
|
||||||
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
|
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
|
||||||
|
|||||||
@@ -1,28 +1,71 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { DataSource } from 'typeorm';
|
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 {
|
||||||
|
CreateInventoryFamilyDto,
|
||||||
|
ReplaceInventoryFamilyFindingsDto,
|
||||||
|
UpdateInventoryFamilyDto,
|
||||||
|
} from './dto/inventory-family-admin.dto';
|
||||||
|
|
||||||
|
type FamilyRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||||
|
informationLabels: string[];
|
||||||
|
sourceReference: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
parentFamilyId: string | null;
|
||||||
|
parentFamilyCode: string | null;
|
||||||
|
parentFamilyName: string | null;
|
||||||
|
assetCount: number;
|
||||||
|
findingCount: number;
|
||||||
|
findingItemIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InventoryFamilyCatalogService {
|
export class InventoryFamilyCatalogService {
|
||||||
constructor(private readonly dataSource: DataSource) {}
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listAdmin() {
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
family.id,family.code,family.name,family.level,
|
||||||
|
family.information_labels AS "informationLabels",
|
||||||
|
family.source_reference AS "sourceReference",
|
||||||
|
family.is_active AS "isActive",
|
||||||
|
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
|
||||||
|
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
|
||||||
|
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
|
||||||
|
COALESCE((
|
||||||
|
SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id)
|
||||||
|
FROM finding_catalog_item_inventory_families mapping
|
||||||
|
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
|
||||||
|
WHERE mapping.inventory_family_id=family.id
|
||||||
|
),'[]'::jsonb) AS "findingItemIds"
|
||||||
|
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
|
||||||
|
ORDER BY family.level,family.is_active DESC,
|
||||||
|
COALESCE(parent.name,''),family.name,family.code
|
||||||
|
`) as FamilyRow[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
async findings(familyId: string) {
|
async findings(familyId: string) {
|
||||||
const [family] = await this.dataSource.query(`
|
const family = await this.family(familyId, false);
|
||||||
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(`
|
const items = await this.dataSource.query(`
|
||||||
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
|
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
|
||||||
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
|
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
|
||||||
@@ -36,4 +79,221 @@ export class InventoryFamilyCatalogService {
|
|||||||
`, [familyId]);
|
`, [familyId]);
|
||||||
return { family, items, count: items.length };
|
return { family, items, count: items.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateInventoryFamilyDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null);
|
||||||
|
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
|
||||||
|
const [inserted] = (await manager.query(`
|
||||||
|
INSERT INTO inventory_families(
|
||||||
|
code,name,level,legacy_type_code,information_labels,source_reference,is_active
|
||||||
|
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true)
|
||||||
|
RETURNING id
|
||||||
|
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
|
||||||
|
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
|
||||||
|
if (parentId) {
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
|
||||||
|
VALUES ($1::uuid,$2::uuid)
|
||||||
|
`,[inserted.id,parentId]);
|
||||||
|
}
|
||||||
|
const created = await this.family(inserted.id,false,manager);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal,request),
|
||||||
|
action: AuditAction.ASSET_UPDATED,
|
||||||
|
entityType: 'inventory_family',
|
||||||
|
entityId: inserted.id,
|
||||||
|
afterData: created as unknown as Record<string,unknown>,
|
||||||
|
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' },
|
||||||
|
},manager);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
familyId: string,
|
||||||
|
dto: UpdateInventoryFamilyDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const before = await this.family(familyId,false,manager,true);
|
||||||
|
const nextParentId = dto.parentFamilyId === undefined
|
||||||
|
? before.parentFamilyId
|
||||||
|
: dto.parentFamilyId;
|
||||||
|
const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId);
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE inventory_families SET
|
||||||
|
name=COALESCE($2::varchar,name),
|
||||||
|
information_labels=COALESCE($3::jsonb,information_labels),
|
||||||
|
is_active=COALESCE($4::boolean,is_active),
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1::uuid
|
||||||
|
`,[
|
||||||
|
familyId,
|
||||||
|
dto.name ?? null,
|
||||||
|
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
|
||||||
|
dto.isActive ?? null,
|
||||||
|
]);
|
||||||
|
if (before.level==='SUBINSTALLATION') {
|
||||||
|
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
|
||||||
|
if (parentId) {
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
|
||||||
|
VALUES ($1::uuid,$2::uuid)
|
||||||
|
`,[familyId,parentId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const after = await this.family(familyId,false,manager);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal,request),
|
||||||
|
action: AuditAction.ASSET_UPDATED,
|
||||||
|
entityType: 'inventory_family',
|
||||||
|
entityId: familyId,
|
||||||
|
beforeData: before as unknown as Record<string,unknown>,
|
||||||
|
afterData: after as unknown as Record<string,unknown>,
|
||||||
|
metadata: { operation:'INVENTORY_FAMILY_UPDATED' },
|
||||||
|
},manager);
|
||||||
|
return after;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async replaceFindings(
|
||||||
|
familyId: string,
|
||||||
|
dto: ReplaceInventoryFamilyFindingsDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const family = await this.family(familyId,false,manager,true);
|
||||||
|
const uniqueIds=[...new Set(dto.itemIds)];
|
||||||
|
if (uniqueIds.length) {
|
||||||
|
const [count] = (await manager.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM finding_catalog_items item
|
||||||
|
JOIN finding_categories category ON category.id=item.category_id
|
||||||
|
WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true
|
||||||
|
`,[uniqueIds])) as Array<{total:number}>;
|
||||||
|
if (Number(count?.total ?? 0)!==uniqueIds.length) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code:'INVENTORY_FAMILY_FINDING_INVALID',
|
||||||
|
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const beforeIds=family.findingItemIds;
|
||||||
|
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
|
||||||
|
if (uniqueIds.length) {
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
|
||||||
|
SELECT item_id,$2::uuid FROM UNNEST($1::uuid[]) AS selected(item_id)
|
||||||
|
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
|
||||||
|
`,[uniqueIds,familyId]);
|
||||||
|
}
|
||||||
|
const after=await this.family(familyId,false,manager);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal,request),
|
||||||
|
action: AuditAction.ASSET_UPDATED,
|
||||||
|
entityType:'inventory_family_findings',
|
||||||
|
entityId:familyId,
|
||||||
|
beforeData:{ itemIds:beforeIds },
|
||||||
|
afterData:{ itemIds:after.findingItemIds },
|
||||||
|
metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason },
|
||||||
|
},manager);
|
||||||
|
return this.findingsWithManager(manager,familyId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findingsWithManager(manager:EntityManager,familyId:string) {
|
||||||
|
const family=await this.family(familyId,false,manager);
|
||||||
|
const items=await manager.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};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async family(
|
||||||
|
familyId:string,
|
||||||
|
activeOnly:boolean,
|
||||||
|
manager:EntityManager=this.dataSource.manager,
|
||||||
|
lock=false,
|
||||||
|
):Promise<FamilyRow> {
|
||||||
|
const rows=(await manager.query(`
|
||||||
|
SELECT family.id,family.code,family.name,family.level,
|
||||||
|
family.information_labels AS "informationLabels",
|
||||||
|
family.source_reference AS "sourceReference",family.is_active AS "isActive",
|
||||||
|
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
|
||||||
|
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
|
||||||
|
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
|
||||||
|
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
|
||||||
|
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds"
|
||||||
|
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 ${activeOnly ? 'AND family.is_active=true' : ''}
|
||||||
|
${lock ? 'FOR UPDATE OF family' : ''}
|
||||||
|
`,[familyId])) as FamilyRow[];
|
||||||
|
if (!rows[0]) throw new NotFoundException({
|
||||||
|
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
|
||||||
|
});
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateParent(
|
||||||
|
manager:EntityManager,
|
||||||
|
level:'INSTALLATION'|'SUBINSTALLATION',
|
||||||
|
parentFamilyId:string|null,
|
||||||
|
ownId:string|null,
|
||||||
|
):Promise<string|null> {
|
||||||
|
if (level==='INSTALLATION') {
|
||||||
|
if (parentFamilyId) throw new BadRequestException({
|
||||||
|
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
|
||||||
|
message:'Una clasificación de Instalación no tiene clasificación padre',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parentFamilyId) throw new BadRequestException({
|
||||||
|
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
|
||||||
|
message:'Una Subinstalación debe pertenecer a un tipo de Instalación',
|
||||||
|
});
|
||||||
|
if (parentFamilyId===ownId) throw new BadRequestException({
|
||||||
|
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
|
||||||
|
});
|
||||||
|
const rows=(await manager.query(`
|
||||||
|
SELECT id FROM inventory_families
|
||||||
|
WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true
|
||||||
|
LIMIT 1
|
||||||
|
`,[parentFamilyId])) as Array<{id:string}>;
|
||||||
|
if (!rows[0]) throw new BadRequestException({
|
||||||
|
code:'INVENTORY_FAMILY_PARENT_INVALID',
|
||||||
|
message:'La Subinstalación debe vincularse a una clasificación de Instalación activa',
|
||||||
|
});
|
||||||
|
return parentFamilyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanLabels(labels:string[]):string[] {
|
||||||
|
const unique=new Map<string,string>();
|
||||||
|
for (const raw of labels) {
|
||||||
|
const clean=raw.trim();
|
||||||
|
if (!clean) continue;
|
||||||
|
const identity=clean.toLocaleLowerCase('es-AR');
|
||||||
|
if (!unique.has(identity)) unique.set(identity,clean);
|
||||||
|
}
|
||||||
|
if (unique.size>100) throw new ConflictException({
|
||||||
|
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
|
||||||
|
});
|
||||||
|
return [...unique.values()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
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 {
|
||||||
|
ChangeInventoryFunctionDto,
|
||||||
|
CreateInventoryFunctionDto,
|
||||||
|
UpdateInventoryFunctionDto,
|
||||||
|
} from './dto/inventory-function.dto';
|
||||||
|
import { InventoryFunctionService } from './inventory-function.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class InventoryFunctionController {
|
||||||
|
constructor(private readonly functions: InventoryFunctionService) {}
|
||||||
|
|
||||||
|
@Get('inventory-functions')
|
||||||
|
@RequirePermissions('assets.read')
|
||||||
|
list(@Query('includeInactive') includeInactive?: string) {
|
||||||
|
return this.functions.list(includeInactive === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('inventory-functions')
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateInventoryFunctionDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.functions.create(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('inventory-functions/:id')
|
||||||
|
@RequirePermissions('asset_types.manage')
|
||||||
|
update(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: UpdateInventoryFunctionDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.functions.update(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('assets/:id/function-history')
|
||||||
|
@RequirePermissions('assets.read_history')
|
||||||
|
history(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.functions.getForAsset(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('assets/:id/function')
|
||||||
|
@RequirePermissions('assets.update')
|
||||||
|
change(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: ChangeInventoryFunctionDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.functions.changeForAsset(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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 type {
|
||||||
|
ChangeInventoryFunctionDto,
|
||||||
|
CreateInventoryFunctionDto,
|
||||||
|
UpdateInventoryFunctionDto,
|
||||||
|
} from './dto/inventory-function.dto';
|
||||||
|
import { AssetHistoryService } from './asset-history.service';
|
||||||
|
|
||||||
|
export interface InventoryFunctionRow {
|
||||||
|
id: string; code: string; name: string; description: string | null;
|
||||||
|
isActive: boolean; sortOrder: number; createdAt: Date; updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunctionAssignmentRow {
|
||||||
|
id: string; assetId: string; functionId: string; functionCode: string; functionName: string;
|
||||||
|
validFrom: Date; validUntil: Date | null; reason: string | null;
|
||||||
|
changedBy: string | null; changedByUsername: string | null; createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunctionEligibleAsset {
|
||||||
|
id: string; code: string; name: string; typeCode: string; typeName: string;
|
||||||
|
familyCode: string | null; familyName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalized(value: string | null): string {
|
||||||
|
return (value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InventoryFunctionService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly history: AssetHistoryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(includeInactive = false): Promise<{ data: InventoryFunctionRow[] }> {
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
|
||||||
|
created_at AS "createdAt",updated_at AS "updatedAt"
|
||||||
|
FROM inventory_functions
|
||||||
|
${includeInactive ? '' : 'WHERE is_active=true'}
|
||||||
|
ORDER BY sort_order,name,code
|
||||||
|
`) as InventoryFunctionRow[];
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise<InventoryFunctionRow> {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const [duplicate] = await manager.query('SELECT 1 FROM inventory_functions WHERE lower(code)=lower($1) LIMIT 1', [dto.code]) as unknown[];
|
||||||
|
if (duplicate) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CODE_EXISTS', message: 'Ya existe una función con ese código' });
|
||||||
|
const [created] = await manager.query(`
|
||||||
|
INSERT INTO inventory_functions(code,name,description,is_active,sort_order,created_by,updated_by)
|
||||||
|
VALUES($1,$2,$3,true,$4,$5,$5)
|
||||||
|
RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
|
||||||
|
created_at AS "createdAt",updated_at AS "updatedAt"
|
||||||
|
`, [dto.code, dto.name, dto.description ?? null, dto.sortOrder ?? 0, principal.userId]) as InventoryFunctionRow[];
|
||||||
|
await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_CREATED,
|
||||||
|
entityType: 'inventory_function', entityId: created.id, afterData: created as unknown as Record<string, unknown> }, manager);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise<InventoryFunctionRow> {
|
||||||
|
if (Object.keys(dto).length === 0) throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const before = await this.requireFunction(manager, id, false);
|
||||||
|
const [updated] = await manager.query(`
|
||||||
|
UPDATE inventory_functions SET
|
||||||
|
name=COALESCE($2,name), description=CASE WHEN $3::boolean THEN $4 ELSE description END,
|
||||||
|
is_active=COALESCE($5,is_active), sort_order=COALESCE($6,sort_order),
|
||||||
|
updated_by=$7,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
|
||||||
|
created_at AS "createdAt",updated_at AS "updatedAt"
|
||||||
|
`, [id, dto.name ?? null, dto.description !== undefined, dto.description ?? null, dto.isActive ?? null, dto.sortOrder ?? null, principal.userId]) as InventoryFunctionRow[];
|
||||||
|
await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_UPDATED,
|
||||||
|
entityType: 'inventory_function', entityId: id, beforeData: before as unknown as Record<string, unknown>,
|
||||||
|
afterData: updated as unknown as Record<string, unknown> }, manager);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getForAsset(assetId: string) {
|
||||||
|
return this.dataSource.transaction((manager) => this.getForAssetWithManager(manager, assetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeForAsset(assetId: string, dto: ChangeInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext) {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const asset = await this.requireEligibleAsset(manager, assetId, true);
|
||||||
|
const nextFunction = await this.requireFunction(manager, dto.functionId, true);
|
||||||
|
const effectiveAt = dto.effectiveAt ? new Date(dto.effectiveAt) : new Date();
|
||||||
|
if (!Number.isFinite(effectiveAt.getTime())) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_INVALID', message: 'La fecha efectiva del cambio de función no es válida' });
|
||||||
|
if (effectiveAt.getTime() > Date.now() + 60_000) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', message: 'El cambio de función no puede registrarse con fecha futura' });
|
||||||
|
|
||||||
|
const current = await this.currentAssignment(manager, assetId, true);
|
||||||
|
if (current?.functionId === nextFunction.id) return this.getForAssetWithManager(manager, assetId);
|
||||||
|
if (current && effectiveAt.getTime() <= new Date(current.validFrom).getTime()) {
|
||||||
|
throw new ConflictException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_OVERLAP', message: 'La fecha efectiva debe ser posterior al inicio de la función vigente' });
|
||||||
|
}
|
||||||
|
if (current) await manager.query(`UPDATE inventory_function_assignments SET valid_until=$2 WHERE id=$1 AND valid_until IS NULL`, [current.id, effectiveAt]);
|
||||||
|
await manager.query(`INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason,changed_by) VALUES($1,$2,$3,$4,$5)`,
|
||||||
|
[assetId, nextFunction.id, effectiveAt, dto.reason ?? null, principal.userId]);
|
||||||
|
await manager.query(`UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [assetId, principal.userId]);
|
||||||
|
|
||||||
|
const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.FUNCTION_CHANGED, principal, request);
|
||||||
|
const after = await this.getForAssetWithManager(manager, assetId);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request), action: AuditAction.ASSET_FUNCTION_CHANGED,
|
||||||
|
entityType: 'asset', entityId: assetId,
|
||||||
|
beforeData: current ? { functionId: current.functionId, functionCode: current.functionCode, functionName: current.functionName, validFrom: current.validFrom } : { functionId: null },
|
||||||
|
afterData: { functionId: nextFunction.id, functionCode: nextFunction.code, functionName: nextFunction.name, effectiveAt, reason: dto.reason ?? null, versionNumber },
|
||||||
|
metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, historySource: 'inventory_function_assignments', assetVersionChangeType: AssetVersionChangeType.FUNCTION_CHANGED },
|
||||||
|
}, manager);
|
||||||
|
return after;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getForAssetWithManager(manager: EntityManager, assetId: string) {
|
||||||
|
const asset = await this.requireEligibleAsset(manager, assetId, false);
|
||||||
|
const currentFunction = await this.currentAssignment(manager, assetId, false);
|
||||||
|
const history = await manager.query(`
|
||||||
|
SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId",
|
||||||
|
fn.code AS "functionCode",fn.name AS "functionName",
|
||||||
|
assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil",
|
||||||
|
assignment.change_reason AS reason,assignment.changed_by AS "changedBy",
|
||||||
|
actor.username AS "changedByUsername",assignment.created_at AS "createdAt"
|
||||||
|
FROM inventory_function_assignments assignment
|
||||||
|
JOIN inventory_functions fn ON fn.id=assignment.function_id
|
||||||
|
LEFT JOIN users actor ON actor.id=assignment.changed_by
|
||||||
|
WHERE assignment.asset_id=$1
|
||||||
|
ORDER BY assignment.valid_from DESC,assignment.created_at DESC
|
||||||
|
`, [assetId]) as FunctionAssignmentRow[];
|
||||||
|
return { asset: { id: asset.id, code: asset.code, name: asset.name, typeCode: asset.typeCode, typeName: asset.typeName, familyCode: asset.familyCode, familyName: asset.familyName }, currentFunction, history };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async currentAssignment(manager: EntityManager, assetId: string, lock: boolean): Promise<FunctionAssignmentRow | null> {
|
||||||
|
const [row] = await manager.query(`
|
||||||
|
SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId",
|
||||||
|
fn.code AS "functionCode",fn.name AS "functionName",
|
||||||
|
assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil",
|
||||||
|
assignment.change_reason AS reason,assignment.changed_by AS "changedBy",
|
||||||
|
actor.username AS "changedByUsername",assignment.created_at AS "createdAt"
|
||||||
|
FROM inventory_function_assignments assignment
|
||||||
|
JOIN inventory_functions fn ON fn.id=assignment.function_id
|
||||||
|
LEFT JOIN users actor ON actor.id=assignment.changed_by
|
||||||
|
WHERE assignment.asset_id=$1 AND assignment.valid_until IS NULL
|
||||||
|
${lock ? 'FOR UPDATE OF assignment' : ''}
|
||||||
|
`, [assetId]) as FunctionAssignmentRow[];
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireFunction(manager: EntityManager, id: string, active: boolean): Promise<InventoryFunctionRow> {
|
||||||
|
const [row] = await manager.query(`
|
||||||
|
SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",created_at AS "createdAt",updated_at AS "updatedAt"
|
||||||
|
FROM inventory_functions WHERE id=$1 ${active ? 'AND is_active=true' : ''}
|
||||||
|
`, [id]) as InventoryFunctionRow[];
|
||||||
|
if (!row) throw new NotFoundException({ code: 'INVENTORY_FUNCTION_NOT_FOUND', message: active ? 'La función seleccionada no existe o está inactiva' : 'Función no encontrada' });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireEligibleAsset(manager: EntityManager, assetId: string, lock: boolean): Promise<FunctionEligibleAsset> {
|
||||||
|
const [asset] = await manager.query(`
|
||||||
|
SELECT asset.id,asset.code,asset.name,asset_type.code AS "typeCode",asset_type.name AS "typeName",
|
||||||
|
family.code AS "familyCode",family.name AS "familyName"
|
||||||
|
FROM assets asset JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
|
||||||
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
|
WHERE asset.id=$1 ${lock ? 'FOR UPDATE OF asset' : ''}
|
||||||
|
`, [assetId]) as FunctionEligibleAsset[];
|
||||||
|
if (!asset) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' });
|
||||||
|
const values = [asset.typeCode, asset.typeName, asset.familyCode, asset.familyName].map(normalized);
|
||||||
|
const eligible = values.some((value) => value === 'estacion' || value === 'subestacion' || value.includes('estacion ') || value.includes('subestacion ') || value.endsWith(' estacion') || value.endsWith(' subestacion'));
|
||||||
|
if (!eligible) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', message: 'El cambio de función sólo está habilitado para Inventarios de Estación o Subestación' });
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,22 @@ type MergeRow = {
|
|||||||
requestId: string | null;
|
requestId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DocumentInvariantRow = {
|
||||||
|
actId: string;
|
||||||
|
actStatus: string;
|
||||||
|
lockedSha256: string | null;
|
||||||
|
closureSha256: string | null;
|
||||||
|
sealedAt: Date | null;
|
||||||
|
actVersion: number;
|
||||||
|
reportId: string | null;
|
||||||
|
reportStatus: string | null;
|
||||||
|
reportActClosureSha256: string | null;
|
||||||
|
reportFrozenSha256: string | null;
|
||||||
|
gedoPdfSha256: string | null;
|
||||||
|
wordSha256: string | null;
|
||||||
|
reportRevision: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
|
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -161,6 +177,19 @@ export class InventoryMergeService {
|
|||||||
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
||||||
|
|
||||||
this.validatePair(source, canonical);
|
this.validatePair(source, canonical);
|
||||||
|
|
||||||
|
// Empresa is temporal inspection context, not physical ownership. A merge is
|
||||||
|
// valid when both records resolve to the same Area in the physical hierarchy,
|
||||||
|
// even if their historical operator snapshots differ.
|
||||||
|
const [sourceAreaId, canonicalAreaId] = await Promise.all([
|
||||||
|
this.resolvePhysicalAreaId(manager, source.id),
|
||||||
|
this.resolvePhysicalAreaId(manager, canonical.id),
|
||||||
|
]);
|
||||||
|
if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({
|
||||||
|
code: 'INVENTORY_MERGE_AREA_MISMATCH',
|
||||||
|
message: 'Los duplicados deben pertenecer a la misma Área física',
|
||||||
|
});
|
||||||
|
|
||||||
const sourceParentCanonical = source.parentId
|
const sourceParentCanonical = source.parentId
|
||||||
? await this.resolveCanonicalId(manager, source.parentId)
|
? await this.resolveCanonicalId(manager, source.parentId)
|
||||||
: null;
|
: null;
|
||||||
@@ -197,6 +226,8 @@ export class InventoryMergeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const affectedAssetIds = [source.id, canonical.id];
|
||||||
|
const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds);
|
||||||
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
|
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
|
||||||
this.snapshot(manager, source.id),
|
this.snapshot(manager, source.id),
|
||||||
this.snapshot(manager, canonical.id),
|
this.snapshot(manager, canonical.id),
|
||||||
@@ -279,6 +310,17 @@ export class InventoryMergeService {
|
|||||||
request,
|
request,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// No historical Acta/Finding/Informe foreign key is rewritten by a merge.
|
||||||
|
// Verify that legal/documentary fingerprints are byte-for-byte unchanged
|
||||||
|
// before committing the transaction; otherwise rollback the entire merge.
|
||||||
|
const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds);
|
||||||
|
if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN',
|
||||||
|
message: 'La fusión intentó alterar la huella documental histórica y fue revertida',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
merge: mergeRecord,
|
merge: mergeRecord,
|
||||||
source: { id: source.id, code: source.code, name: source.name },
|
source: { id: source.id, code: source.code, name: source.name },
|
||||||
@@ -286,6 +328,7 @@ export class InventoryMergeService {
|
|||||||
sourceVersionNumber,
|
sourceVersionNumber,
|
||||||
reparentedChildIds,
|
reparentedChildIds,
|
||||||
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
|
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
|
||||||
|
documentaryInvariantsVerified: true,
|
||||||
};
|
};
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
...administrationAuditContext(principal, request),
|
...administrationAuditContext(principal, request),
|
||||||
@@ -298,10 +341,12 @@ export class InventoryMergeService {
|
|||||||
operation: 'CHRONOLOGICAL_MERGE',
|
operation: 'CHRONOLOGICAL_MERGE',
|
||||||
sourceAssetId: source.id,
|
sourceAssetId: source.id,
|
||||||
canonicalAssetId: canonical.id,
|
canonicalAssetId: canonical.id,
|
||||||
|
physicalAreaId: sourceAreaId,
|
||||||
reason: dto.reason,
|
reason: dto.reason,
|
||||||
sourceVersionNumber,
|
sourceVersionNumber,
|
||||||
reparentedChildIds,
|
reparentedChildIds,
|
||||||
historicalReferencesRewritten: false,
|
historicalReferencesRewritten: false,
|
||||||
|
documentaryInvariantsVerified: true,
|
||||||
},
|
},
|
||||||
}, manager);
|
}, manager);
|
||||||
return result;
|
return result;
|
||||||
@@ -322,14 +367,59 @@ export class InventoryMergeService {
|
|||||||
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
|
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
|
||||||
message: 'El registro canónico no puede estar inactivo',
|
message: 'El registro canónico no puede estar inactivo',
|
||||||
});
|
});
|
||||||
if (!source.operationalAreaId || !source.operatorCompanyId
|
}
|
||||||
|| source.operationalAreaId !== canonical.operationalAreaId
|
|
||||||
|| source.operatorCompanyId !== canonical.operatorCompanyId) {
|
private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
|
||||||
throw new BadRequestException({
|
const rows = (await manager.query(`
|
||||||
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH',
|
WITH RECURSIVE lineage AS (
|
||||||
message: 'Los registros deben pertenecer a la misma Área y Operadora',
|
SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth
|
||||||
});
|
FROM assets asset WHERE asset.id=$1::uuid
|
||||||
}
|
UNION ALL
|
||||||
|
SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1
|
||||||
|
FROM assets parent
|
||||||
|
JOIN lineage ON lineage.parent_id=parent.id
|
||||||
|
WHERE lineage.depth<32
|
||||||
|
)
|
||||||
|
SELECT lineage.id
|
||||||
|
FROM lineage
|
||||||
|
JOIN asset_types type ON type.id=lineage.asset_type_id
|
||||||
|
WHERE type.operational_role='AREA'
|
||||||
|
ORDER BY lineage.depth
|
||||||
|
LIMIT 1
|
||||||
|
`, [assetId])) as Array<{ id: string }>;
|
||||||
|
return rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise<DocumentInvariantRow[]> {
|
||||||
|
return (await manager.query(`
|
||||||
|
WITH affected_acts AS (
|
||||||
|
SELECT DISTINCT act.id
|
||||||
|
FROM inspection_acts act
|
||||||
|
LEFT JOIN inspection_act_assets act_asset
|
||||||
|
ON act_asset.act_id=act.id AND act_asset.included=true
|
||||||
|
LEFT JOIN inspection_findings finding ON finding.act_id=act.id
|
||||||
|
WHERE act_asset.asset_id=ANY($1::uuid[])
|
||||||
|
OR finding.asset_id=ANY($1::uuid[])
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
act.id AS "actId",
|
||||||
|
act.status AS "actStatus",
|
||||||
|
act.locked_sha256 AS "lockedSha256",
|
||||||
|
act.closure_sha256 AS "closureSha256",
|
||||||
|
act.sealed_at AS "sealedAt",
|
||||||
|
act.current_version AS "actVersion",
|
||||||
|
report.id AS "reportId",
|
||||||
|
report.status AS "reportStatus",
|
||||||
|
report.act_closure_sha256 AS "reportActClosureSha256",
|
||||||
|
report.frozen_sha256 AS "reportFrozenSha256",
|
||||||
|
report.gedo_pdf_sha256 AS "gedoPdfSha256",
|
||||||
|
report.word_sha256 AS "wordSha256",
|
||||||
|
report.current_revision_number AS "reportRevision"
|
||||||
|
FROM affected_acts affected
|
||||||
|
JOIN inspection_acts act ON act.id=affected.id
|
||||||
|
LEFT JOIN inspection_reports report ON report.act_id=act.id
|
||||||
|
ORDER BY act.id,report.id NULLS FIRST
|
||||||
|
`, [assetIds])) as DocumentInvariantRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
|
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
|
||||||
|
|||||||
@@ -36,18 +36,17 @@ type ParentRow = {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
typeCode: string;
|
typeCode: string;
|
||||||
operationalAreaId: string | null;
|
|
||||||
operatorCompanyId: string | null;
|
|
||||||
inventoryFamilyId: string | null;
|
inventoryFamilyId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = {
|
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
|
||||||
AREA: 'area',
|
AREA: 'area',
|
||||||
YACIMIENTO: 'yacimiento',
|
YACIMIENTO: 'yacimiento',
|
||||||
INSTALACION: 'instalacion',
|
INSTALACION: 'instalacion',
|
||||||
SUBINSTALACION: 'subinstalacion',
|
SUBINSTALACION: 'subinstalacion',
|
||||||
};
|
};
|
||||||
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
|
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
|
||||||
|
EMPRESA: null,
|
||||||
AREA: null,
|
AREA: null,
|
||||||
YACIMIENTO: 'area',
|
YACIMIENTO: 'area',
|
||||||
INSTALACION: 'yacimiento',
|
INSTALACION: 'yacimiento',
|
||||||
@@ -70,18 +69,29 @@ export class InventoryStructureService {
|
|||||||
const types = (await this.dataSource.query(`
|
const types = (await this.dataSource.query(`
|
||||||
SELECT id,code,name
|
SELECT id,code,name
|
||||||
FROM asset_types
|
FROM asset_types
|
||||||
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
|
WHERE (
|
||||||
AND is_active=true
|
lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
|
||||||
ORDER BY CASE lower(code)
|
OR operational_role='COMPANY'
|
||||||
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2
|
) AND is_active=true
|
||||||
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END
|
ORDER BY CASE
|
||||||
|
WHEN operational_role='COMPANY' THEN 0
|
||||||
|
WHEN lower(code)='area' THEN 1
|
||||||
|
WHEN lower(code)='yacimiento' THEN 2
|
||||||
|
WHEN lower(code)='instalacion' THEN 3
|
||||||
|
WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END
|
||||||
`)) as StructureTypeRow[];
|
`)) as StructureTypeRow[];
|
||||||
if (types.length !== 4) {
|
const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
|
||||||
|
const area = types.find((item) => item.code.toLowerCase()==='area');
|
||||||
|
const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
|
||||||
|
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
|
||||||
|
const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion');
|
||||||
|
if (!company || !area || !yacimiento || !instalacion || !subinstalacion) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
|
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
|
||||||
message: 'La estructura del Inventario todavía no está completamente configurada',
|
message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const families = (await this.dataSource.query(`
|
const families = (await this.dataSource.query(`
|
||||||
SELECT family.id,family.code,family.name,family.level,
|
SELECT family.id,family.code,family.name,family.level,
|
||||||
family.legacy_type_code AS "legacyTypeCode",
|
family.legacy_type_code AS "legacyTypeCode",
|
||||||
@@ -93,12 +103,16 @@ export class InventoryStructureService {
|
|||||||
WHERE family.is_active=true
|
WHERE family.is_active=true
|
||||||
ORDER BY family.level,family.name,family.code
|
ORDER BY family.level,family.name,family.code
|
||||||
`)) as FamilyRow[];
|
`)) as FamilyRow[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
independentMasters: [
|
||||||
|
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
|
||||||
|
],
|
||||||
levels: [
|
levels: [
|
||||||
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false },
|
{ kind: 'AREA', label: 'Área', type: area, parentKind: null, requiresFamily: false },
|
||||||
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
|
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
|
||||||
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
|
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
|
||||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
|
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
|
||||||
],
|
],
|
||||||
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
|
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
|
||||||
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
|
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
|
||||||
@@ -107,7 +121,7 @@ export class InventoryStructureService {
|
|||||||
|
|
||||||
async parents(kindValue: string, search?: string) {
|
async parents(kindValue: string, search?: string) {
|
||||||
const kind = kindValue.toUpperCase() as InventoryStructureKind;
|
const kind = kindValue.toUpperCase() as InventoryStructureKind;
|
||||||
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') {
|
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
|
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
|
||||||
message: 'El nivel indicado no requiere un registro padre',
|
message: 'El nivel indicado no requiere un registro padre',
|
||||||
@@ -154,9 +168,8 @@ export class InventoryStructureService {
|
|||||||
const type = await this.requireStructureType(manager, dto.kind);
|
const type = await this.requireStructureType(manager, dto.kind);
|
||||||
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
|
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
|
||||||
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
|
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
|
||||||
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
||||||
const operationalAreaId = parent?.operationalAreaId ?? null;
|
const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null;
|
||||||
const operatorCompanyId = parent?.operatorCompanyId ?? null;
|
|
||||||
|
|
||||||
const inserted = (await manager.query(`
|
const inserted = (await manager.query(`
|
||||||
INSERT INTO assets (
|
INSERT INTO assets (
|
||||||
@@ -164,30 +177,37 @@ export class InventoryStructureService {
|
|||||||
code,name,common_name,description,information_status,operational_status,
|
code,name,common_name,description,information_status,operational_status,
|
||||||
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,
|
$1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
|
||||||
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status,
|
$5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
|
||||||
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid
|
$11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
|
||||||
) RETURNING id
|
) RETURNING id
|
||||||
`, [
|
`, [
|
||||||
type.id,
|
type.id,
|
||||||
parent?.id ?? null,
|
parent?.id ?? null,
|
||||||
operationalAreaId,
|
operationalAreaId,
|
||||||
operatorCompanyId,
|
|
||||||
family?.id ?? null,
|
family?.id ?? null,
|
||||||
code,
|
generatedCode,
|
||||||
dto.name,
|
dto.name,
|
||||||
dto.commonName ?? null,
|
dto.commonName ?? null,
|
||||||
dto.description ?? null,
|
dto.description ?? null,
|
||||||
AssetInformationStatus.DRAFT,
|
AssetInformationStatus.DRAFT,
|
||||||
AssetOperationalStatus.UNKNOWN,
|
AssetOperationalStatus.UNKNOWN,
|
||||||
AssetDataOrigin.MANUAL,
|
AssetDataOrigin.MANUAL,
|
||||||
'Inventario estructural F3.1',
|
dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5',
|
||||||
`inventory-structure:${dto.kind.toLowerCase()}`,
|
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
|
||||||
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
|
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
|
||||||
principal.userId,
|
principal.userId,
|
||||||
])) as Array<{ id: string }>;
|
])) as Array<{ id: string }>;
|
||||||
const id = inserted[0]?.id;
|
const id = inserted[0]?.id;
|
||||||
if (!id) throw new Error('No se pudo crear el registro estructural');
|
if (!id) throw new Error('No se pudo crear el registro');
|
||||||
|
|
||||||
|
if (dto.kind === 'EMPRESA') {
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
|
||||||
|
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
|
||||||
|
ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`,[id,dto.name,principal.userId]);
|
||||||
|
}
|
||||||
|
|
||||||
const versionNumber = await this.history.capture(
|
const versionNumber = await this.history.capture(
|
||||||
manager,
|
manager,
|
||||||
@@ -200,13 +220,12 @@ export class InventoryStructureService {
|
|||||||
INSERT INTO asset_context_history (
|
INSERT INTO asset_context_history (
|
||||||
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
||||||
change_reason,asset_version_number,source,request_id,created_by
|
change_reason,asset_version_number,source,request_id,created_by
|
||||||
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
|
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
|
||||||
`, [
|
`, [
|
||||||
id,
|
id,
|
||||||
parent?.id ?? null,
|
parent?.id ?? null,
|
||||||
operationalAreaId,
|
operationalAreaId,
|
||||||
operatorCompanyId,
|
dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5',
|
||||||
'Alta guiada de Inventario estructural F3.1',
|
|
||||||
versionNumber,
|
versionNumber,
|
||||||
request.requestId,
|
request.requestId,
|
||||||
principal.userId,
|
principal.userId,
|
||||||
@@ -223,6 +242,7 @@ export class InventoryStructureService {
|
|||||||
inventoryStructureKind: dto.kind,
|
inventoryStructureKind: dto.kind,
|
||||||
inventoryFamilyId: family?.id ?? null,
|
inventoryFamilyId: family?.id ?? null,
|
||||||
inventoryFamilyCode: family?.code ?? null,
|
inventoryFamilyCode: family?.code ?? null,
|
||||||
|
operatorOwnership: false,
|
||||||
},
|
},
|
||||||
}, manager);
|
}, manager);
|
||||||
return created;
|
return created;
|
||||||
@@ -231,7 +251,7 @@ export class InventoryStructureService {
|
|||||||
if (isUniqueViolation(error)) {
|
if (isUniqueViolation(error)) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'ASSET_CODE_ALREADY_EXISTS',
|
code: 'ASSET_CODE_ALREADY_EXISTS',
|
||||||
message: 'Ya existe un registro de Inventario con ese código',
|
message: 'Ya existe un registro con ese código',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
@@ -239,10 +259,11 @@ export class InventoryStructureService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
||||||
const rows = (await manager.query(`
|
const sql = kind === 'EMPRESA'
|
||||||
SELECT id,code,name FROM asset_types
|
? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`
|
||||||
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1
|
: `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[];
|
const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude<InventoryStructureKind,'EMPRESA'>]];
|
||||||
|
const rows = (await manager.query(sql,params)) as StructureTypeRow[];
|
||||||
if (!rows[0]) {
|
if (!rows[0]) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
|
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
|
||||||
@@ -261,8 +282,10 @@ export class InventoryStructureService {
|
|||||||
if (!expectedType) {
|
if (!expectedType) {
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'INVENTORY_AREA_MUST_BE_ROOT',
|
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
|
||||||
message: 'Un Área se crea como registro raíz y no puede tener padre',
|
message: kind === 'EMPRESA'
|
||||||
|
? 'Una Empresa es un maestro independiente y no puede tener padre'
|
||||||
|
: 'Un Área es un registro raíz y no puede tener padre',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -275,8 +298,6 @@ export class InventoryStructureService {
|
|||||||
}
|
}
|
||||||
const rows = (await manager.query(`
|
const rows = (await manager.query(`
|
||||||
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
||||||
asset.operational_area_id AS "operationalAreaId",
|
|
||||||
asset.operator_company_id AS "operatorCompanyId",
|
|
||||||
asset.inventory_family_id AS "inventoryFamilyId"
|
asset.inventory_family_id AS "inventoryFamilyId"
|
||||||
FROM assets asset
|
FROM assets asset
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
@@ -294,6 +315,30 @@ export class InventoryStructureService {
|
|||||||
return parent;
|
return parent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
|
||||||
|
if (parent.typeCode.toLowerCase()==='area') return parent.id;
|
||||||
|
const rows = (await manager.query(`
|
||||||
|
WITH RECURSIVE lineage AS (
|
||||||
|
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.id,parent.parent_id,parent.asset_type_id
|
||||||
|
FROM assets parent JOIN lineage child ON child.parent_id=parent.id
|
||||||
|
)
|
||||||
|
SELECT lineage.id
|
||||||
|
FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id
|
||||||
|
WHERE type.operational_role='AREA'
|
||||||
|
LIMIT 1
|
||||||
|
`,[parent.id])) as IdRow[];
|
||||||
|
const areaId=rows[0]?.id;
|
||||||
|
if (!areaId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
|
||||||
|
message:'La ubicación seleccionada no pertenece a un Área válida',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return areaId;
|
||||||
|
}
|
||||||
|
|
||||||
private async requireFamily(
|
private async requireFamily(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
kind: InventoryStructureKind,
|
kind: InventoryStructureKind,
|
||||||
@@ -304,7 +349,7 @@ export class InventoryStructureService {
|
|||||||
if (!expectedLevel) {
|
if (!expectedLevel) {
|
||||||
if (familyId) throw new BadRequestException({
|
if (familyId) throw new BadRequestException({
|
||||||
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
||||||
message: 'Área y Yacimiento no llevan familia técnica',
|
message: 'Empresa, Área y Yacimiento no llevan familia técnica',
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -339,7 +384,7 @@ export class InventoryStructureService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
||||||
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
|
const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
|
||||||
const readable = name
|
const readable = name
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
@@ -356,6 +401,7 @@ export class InventoryStructureService {
|
|||||||
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
|
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
|
||||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||||
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
||||||
|
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
|
||||||
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
|
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
|
||||||
'informationLabels',family.information_labels
|
'informationLabels',family.information_labels
|
||||||
@@ -364,9 +410,12 @@ export class InventoryStructureService {
|
|||||||
FROM assets asset
|
FROM assets asset
|
||||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
LEFT JOIN assets parent ON parent.id=asset.parent_id
|
||||||
|
LEFT JOIN assets area ON area.id=asset.operational_area_id
|
||||||
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
WHERE asset.id=$1::uuid
|
WHERE asset.id=$1::uuid
|
||||||
`, [id]);
|
`, [id]);
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IdRow = { id: string };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { AssetsService } from './assets.service';
|
import { AssetsService } from './assets.service';
|
||||||
|
import { InventoryFunctionService } from './inventory-function.service';
|
||||||
import { InventoryMergeService } from './inventory-merge.service';
|
import { InventoryMergeService } from './inventory-merge.service';
|
||||||
|
|
||||||
type LooseRecord = Record<string, any>;
|
type LooseRecord = Record<string, any>;
|
||||||
@@ -29,6 +30,7 @@ export class MergedInventoryDossierService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly assets: AssetsService,
|
private readonly assets: AssetsService,
|
||||||
private readonly merges: InventoryMergeService,
|
private readonly merges: InventoryMergeService,
|
||||||
|
private readonly functions: InventoryFunctionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
|
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
|
||||||
@@ -44,7 +46,8 @@ export class MergedInventoryDossierService {
|
|||||||
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
|
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
|
||||||
const dossier = await this.assets.dossier(assetId) as LooseRecord;
|
const dossier = await this.assets.dossier(assetId) as LooseRecord;
|
||||||
const identity = dossier.asset as LooseRecord;
|
const identity = dossier.asset as LooseRecord;
|
||||||
return { assetId, identity, dossier };
|
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
|
||||||
|
return { assetId, identity, dossier, functionDossier };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({
|
const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({
|
||||||
@@ -75,6 +78,15 @@ export class MergedInventoryDossierService {
|
|||||||
const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']);
|
const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']);
|
||||||
const versions = sortDesc(collect('versions'), ['occurredAt']);
|
const versions = sortDesc(collect('versions'), ['occurredAt']);
|
||||||
|
|
||||||
|
const functionHistory = sortDesc(
|
||||||
|
dossiers.flatMap(({ identity, functionDossier }) =>
|
||||||
|
(((functionDossier?.history ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity))),
|
||||||
|
),
|
||||||
|
['validFrom', 'createdAt'],
|
||||||
|
);
|
||||||
|
const canonicalFunctionDossier = dossiers.find((item) => item.assetId === canonical.id)?.functionDossier ?? null;
|
||||||
|
const currentFunction = canonicalFunctionDossier?.currentFunction ?? null;
|
||||||
|
|
||||||
const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) =>
|
const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) =>
|
||||||
((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({
|
((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({
|
||||||
...event,
|
...event,
|
||||||
@@ -91,6 +103,25 @@ export class MergedInventoryDossierService {
|
|||||||
);
|
);
|
||||||
const timeline: LooseRecord[] = dedupeById<LooseRecord>(timelineSource);
|
const timeline: LooseRecord[] = dedupeById<LooseRecord>(timelineSource);
|
||||||
|
|
||||||
|
for (const assignment of functionHistory) {
|
||||||
|
timeline.push({
|
||||||
|
id: `function:${String(assignment.id)}`,
|
||||||
|
kind: 'FUNCTION_CHANGED',
|
||||||
|
occurredAt: assignment.validFrom,
|
||||||
|
title: `Cambio de función · ${String(assignment.functionName)}`,
|
||||||
|
description: assignment.reason ?? null,
|
||||||
|
meta: {
|
||||||
|
functionId: assignment.functionId,
|
||||||
|
functionCode: assignment.functionCode,
|
||||||
|
functionName: assignment.functionName,
|
||||||
|
validFrom: assignment.validFrom,
|
||||||
|
validUntil: assignment.validUntil,
|
||||||
|
changedByUsername: assignment.changedByUsername,
|
||||||
|
historicalInventory: assignment.historicalInventory,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const alias of aliases) {
|
for (const alias of aliases) {
|
||||||
timeline.push({
|
timeline.push({
|
||||||
id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`,
|
id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`,
|
||||||
@@ -121,6 +152,7 @@ export class MergedInventoryDossierService {
|
|||||||
code: canonical.code,
|
code: canonical.code,
|
||||||
name: canonical.name,
|
name: canonical.name,
|
||||||
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
|
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
|
||||||
|
currentFunction,
|
||||||
},
|
},
|
||||||
requestedAsset: {
|
requestedAsset: {
|
||||||
id: requested.id,
|
id: requested.id,
|
||||||
@@ -146,7 +178,10 @@ export class MergedInventoryDossierService {
|
|||||||
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').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,
|
photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length,
|
||||||
reports: reports.length + inspectionReports.length,
|
reports: reports.length + inspectionReports.length,
|
||||||
|
functionChanges: functionHistory.length,
|
||||||
},
|
},
|
||||||
|
currentFunction,
|
||||||
|
functionHistory,
|
||||||
visits,
|
visits,
|
||||||
acts,
|
acts,
|
||||||
findings,
|
findings,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
export interface DashboardAuditItem {
|
export interface DashboardInspectorActivityItem {
|
||||||
id: string;
|
id: string;
|
||||||
occurredAt: Date;
|
occurredAt: Date;
|
||||||
actorUsername: string | null;
|
actorUsername: string | null;
|
||||||
@@ -19,12 +19,13 @@ interface DashboardCountsRow {
|
|||||||
inactiveUsers: number | string;
|
inactiveUsers: number | string;
|
||||||
activeSessions: number | string;
|
activeSessions: number | string;
|
||||||
openFindings: number | string;
|
openFindings: number | string;
|
||||||
awaitingCompanyResponse: number | string;
|
actsInFollowUp: number | string;
|
||||||
overdueCompanyResponses: number | string;
|
findingsWithoutControlDate: number | string;
|
||||||
companyResponsesDueNext7Days: number | string;
|
|
||||||
awaitingVerificationSchedule: number | string;
|
|
||||||
overdueControls: number | string;
|
overdueControls: number | string;
|
||||||
controlsNext30Days: number | string;
|
controlsNext30Days: number | string;
|
||||||
|
reportsWorking: number | string;
|
||||||
|
reportsOfficialized: number | string;
|
||||||
|
sealedActsWithoutReport: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardUpcomingControl {
|
export interface DashboardUpcomingControl {
|
||||||
@@ -49,9 +50,14 @@ export class DashboardService {
|
|||||||
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
|
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
|
||||||
const [counts] = (await manager.query(`
|
const [counts] = (await manager.query(`
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets",
|
(SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status <> 'INACTIVE') AS "totalAssets",
|
||||||
(SELECT COUNT(*) FROM assets WHERE information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
|
(SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
|
||||||
(SELECT COUNT(*) FROM assets asset WHERE asset.information_status <> 'INACTIVE' AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)) AS "assetsWithoutGeometry",
|
(
|
||||||
|
SELECT COUNT(*) FROM assets asset
|
||||||
|
WHERE asset.is_inventory_instance=true
|
||||||
|
AND asset.information_status <> 'INACTIVE'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)
|
||||||
|
) AS "assetsWithoutGeometry",
|
||||||
(SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections",
|
(SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections",
|
||||||
(SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers",
|
(SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers",
|
||||||
(SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers",
|
(SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers",
|
||||||
@@ -61,50 +67,74 @@ export class DashboardService {
|
|||||||
WHERE revoked_at IS NULL
|
WHERE revoked_at IS NULL
|
||||||
AND expires_at > CURRENT_TIMESTAMP
|
AND expires_at > CURRENT_TIMESTAMP
|
||||||
) AS "activeSessions",
|
) AS "activeSessions",
|
||||||
|
(SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN') AS "openFindings",
|
||||||
(
|
(
|
||||||
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN'
|
SELECT COUNT(*)
|
||||||
) AS "openFindings",
|
FROM inspection_acts act
|
||||||
|
WHERE act.status IN ('SEALED','CLOSED','RECTIFIED')
|
||||||
|
AND NOT (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM inspection_findings finding
|
||||||
|
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM inspection_findings finding
|
||||||
|
WHERE finding.act_id=act.id AND finding.status='OPEN'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) AS "actsInFollowUp",
|
||||||
(
|
(
|
||||||
SELECT COUNT(*) FROM inspection_findings
|
SELECT COUNT(*)
|
||||||
WHERE status = 'OPEN' AND company_response_received_on IS NULL
|
FROM inspection_findings finding
|
||||||
) AS "awaitingCompanyResponse",
|
WHERE finding.status = 'OPEN'
|
||||||
|
AND finding.next_control_on IS NULL
|
||||||
|
AND COALESCE((
|
||||||
|
SELECT verification.outcome
|
||||||
|
FROM inspection_finding_verification_visits verification
|
||||||
|
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
|
||||||
|
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
), '') <> 'RESOLVED'
|
||||||
|
) AS "findingsWithoutControlDate",
|
||||||
(
|
(
|
||||||
SELECT COUNT(*) FROM inspection_findings
|
SELECT COUNT(*)
|
||||||
WHERE status = 'OPEN'
|
FROM inspection_findings finding
|
||||||
AND company_response_received_on IS NULL
|
WHERE finding.status = 'OPEN'
|
||||||
AND correction_due_on IS NOT NULL
|
AND finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||||
AND correction_due_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
AND COALESCE((
|
||||||
) AS "overdueCompanyResponses",
|
SELECT verification.outcome
|
||||||
(
|
FROM inspection_finding_verification_visits verification
|
||||||
SELECT COUNT(*) FROM inspection_findings
|
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
|
||||||
WHERE status = 'OPEN'
|
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
|
||||||
AND company_response_received_on IS NULL
|
LIMIT 1
|
||||||
AND correction_due_on BETWEEN
|
), '') <> 'RESOLVED'
|
||||||
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
|
||||||
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 7
|
|
||||||
) AS "companyResponsesDueNext7Days",
|
|
||||||
(
|
|
||||||
SELECT COUNT(*) FROM inspection_findings
|
|
||||||
WHERE status = 'OPEN'
|
|
||||||
AND company_response_received_on IS NOT NULL
|
|
||||||
AND next_control_on IS NULL
|
|
||||||
) AS "awaitingVerificationSchedule",
|
|
||||||
(
|
|
||||||
SELECT COUNT(*) FROM inspection_findings
|
|
||||||
WHERE status = 'OPEN'
|
|
||||||
AND company_response_received_on IS NOT NULL
|
|
||||||
AND next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
|
||||||
) AS "overdueControls",
|
) AS "overdueControls",
|
||||||
(
|
(
|
||||||
SELECT COUNT(*) FROM inspection_findings
|
SELECT COUNT(*)
|
||||||
WHERE status = 'OPEN'
|
FROM inspection_findings finding
|
||||||
AND next_control_on BETWEEN
|
WHERE finding.status = 'OPEN'
|
||||||
|
AND finding.next_control_on BETWEEN
|
||||||
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||||
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 30
|
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 30
|
||||||
) AS "controlsNext30Days"
|
AND COALESCE((
|
||||||
|
SELECT verification.outcome
|
||||||
|
FROM inspection_finding_verification_visits verification
|
||||||
|
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
|
||||||
|
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
), '') <> 'RESOLVED'
|
||||||
|
) AS "controlsNext30Days",
|
||||||
|
(SELECT COUNT(*) FROM inspection_reports WHERE status='WORKING') AS "reportsWorking",
|
||||||
|
(SELECT COUNT(*) FROM inspection_reports WHERE status='OFFICIALIZED') AS "reportsOfficialized",
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM inspection_acts act
|
||||||
|
WHERE act.status='SEALED'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM inspection_reports report WHERE report.act_id=act.id)
|
||||||
|
) AS "sealedActsWithoutReport"
|
||||||
`)) as DashboardCountsRow[];
|
`)) as DashboardCountsRow[];
|
||||||
|
|
||||||
const recentAudit = (await manager.query(`
|
const recentInspectorActivity = (await manager.query(`
|
||||||
SELECT
|
SELECT
|
||||||
event.id,
|
event.id,
|
||||||
event.occurred_at AS "occurredAt",
|
event.occurred_at AS "occurredAt",
|
||||||
@@ -113,9 +143,25 @@ export class DashboardService {
|
|||||||
event.entity_type AS "entityType",
|
event.entity_type AS "entityType",
|
||||||
event.entity_id AS "entityId"
|
event.entity_id AS "entityId"
|
||||||
FROM audit_events event
|
FROM audit_events event
|
||||||
|
WHERE event.actor_user_id IS NOT NULL
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM inspection_visit_members member
|
||||||
|
WHERE member.user_id=event.actor_user_id AND member.included=true
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM inspection_visits visit
|
||||||
|
WHERE visit.lead_inspector_user_id=event.actor_user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
event.action LIKE 'INSPECTION_%'
|
||||||
|
OR event.action LIKE 'ASSET_%'
|
||||||
|
OR event.action LIKE 'FINDING_%'
|
||||||
|
)
|
||||||
ORDER BY event.occurred_at DESC, event.id DESC
|
ORDER BY event.occurred_at DESC, event.id DESC
|
||||||
LIMIT 6
|
LIMIT 8
|
||||||
`)) as DashboardAuditItem[];
|
`)) as DashboardInspectorActivityItem[];
|
||||||
|
|
||||||
const upcomingControls = (await manager.query(`
|
const upcomingControls = (await manager.query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -135,8 +181,14 @@ export class DashboardService {
|
|||||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||||
INNER JOIN assets asset ON asset.id = finding.asset_id
|
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||||
WHERE finding.status = 'OPEN'
|
WHERE finding.status = 'OPEN'
|
||||||
AND finding.company_response_received_on IS NOT NULL
|
|
||||||
AND finding.next_control_on IS NOT NULL
|
AND finding.next_control_on IS NOT NULL
|
||||||
|
AND COALESCE((
|
||||||
|
SELECT verification.outcome
|
||||||
|
FROM inspection_finding_verification_visits verification
|
||||||
|
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
|
||||||
|
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
), '') <> 'RESOLVED'
|
||||||
ORDER BY finding.next_control_on, finding.code
|
ORDER BY finding.next_control_on, finding.code
|
||||||
LIMIT 8
|
LIMIT 8
|
||||||
`)) as DashboardUpcomingControl[];
|
`)) as DashboardUpcomingControl[];
|
||||||
@@ -151,14 +203,15 @@ export class DashboardService {
|
|||||||
inactiveUsers: Number(counts?.inactiveUsers ?? 0),
|
inactiveUsers: Number(counts?.inactiveUsers ?? 0),
|
||||||
activeSessions: Number(counts?.activeSessions ?? 0),
|
activeSessions: Number(counts?.activeSessions ?? 0),
|
||||||
openFindings: Number(counts?.openFindings ?? 0),
|
openFindings: Number(counts?.openFindings ?? 0),
|
||||||
awaitingCompanyResponse: Number(counts?.awaitingCompanyResponse ?? 0),
|
actsInFollowUp: Number(counts?.actsInFollowUp ?? 0),
|
||||||
overdueCompanyResponses: Number(counts?.overdueCompanyResponses ?? 0),
|
findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
|
||||||
companyResponsesDueNext7Days: Number(counts?.companyResponsesDueNext7Days ?? 0),
|
|
||||||
awaitingVerificationSchedule: Number(counts?.awaitingVerificationSchedule ?? 0),
|
|
||||||
overdueControls: Number(counts?.overdueControls ?? 0),
|
overdueControls: Number(counts?.overdueControls ?? 0),
|
||||||
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
|
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
|
||||||
|
reportsWorking: Number(counts?.reportsWorking ?? 0),
|
||||||
|
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
|
||||||
|
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
|
||||||
},
|
},
|
||||||
recentAudit,
|
recentInspectorActivity,
|
||||||
upcomingControls,
|
upcomingControls,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export enum AssetVersionChangeType {
|
|||||||
CREATED = 'CREATED',
|
CREATED = 'CREATED',
|
||||||
UPDATED = 'UPDATED',
|
UPDATED = 'UPDATED',
|
||||||
CONTEXT_CHANGED = 'CONTEXT_CHANGED',
|
CONTEXT_CHANGED = 'CONTEXT_CHANGED',
|
||||||
|
FUNCTION_CHANGED = 'FUNCTION_CHANGED',
|
||||||
STATUS_CHANGED = 'STATUS_CHANGED',
|
STATUS_CHANGED = 'STATUS_CHANGED',
|
||||||
OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED',
|
OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED',
|
||||||
REGISTRY_UPDATED = 'REGISTRY_UPDATED',
|
REGISTRY_UPDATED = 'REGISTRY_UPDATED',
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export class Asset extends TimestampedEntity {
|
|||||||
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
|
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
|
||||||
inventoryFamilyId!: string | null;
|
inventoryFamilyId!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'is_inventory_instance', type: 'boolean', default: false })
|
||||||
|
isInventoryInstance!: boolean;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 120 })
|
@Column({ type: 'varchar', length: 120 })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export enum AuditAction {
|
|||||||
ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED',
|
ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED',
|
||||||
ASSET_UPDATED = 'ASSET_UPDATED',
|
ASSET_UPDATED = 'ASSET_UPDATED',
|
||||||
ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED',
|
ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED',
|
||||||
|
ASSET_FUNCTION_CHANGED = 'ASSET_FUNCTION_CHANGED',
|
||||||
|
INVENTORY_FUNCTION_CREATED = 'INVENTORY_FUNCTION_CREATED',
|
||||||
|
INVENTORY_FUNCTION_UPDATED = 'INVENTORY_FUNCTION_UPDATED',
|
||||||
ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED',
|
ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED',
|
||||||
ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED',
|
ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED',
|
||||||
ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED',
|
ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED',
|
||||||
@@ -82,15 +85,24 @@ export enum AuditAction {
|
|||||||
INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED',
|
INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED',
|
||||||
INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED',
|
INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED',
|
||||||
INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED',
|
INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED',
|
||||||
|
INSPECTION_ACT_LOCKED = 'INSPECTION_ACT_LOCKED',
|
||||||
|
INSPECTION_ACT_SEALED = 'INSPECTION_ACT_SEALED',
|
||||||
INSPECTION_ACT_READY = 'INSPECTION_ACT_READY',
|
INSPECTION_ACT_READY = 'INSPECTION_ACT_READY',
|
||||||
INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED',
|
INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED',
|
||||||
INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED',
|
INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED',
|
||||||
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED',
|
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED',
|
||||||
INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED',
|
INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED',
|
||||||
INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED',
|
INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED',
|
||||||
|
INSPECTION_REPORT_UPDATED = 'INSPECTION_REPORT_UPDATED',
|
||||||
INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED',
|
INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED',
|
||||||
|
INSPECTION_REPORT_OFFICIALIZED = 'INSPECTION_REPORT_OFFICIALIZED',
|
||||||
|
INSPECTION_REPORT_FOLLOW_UP_ADDED = 'INSPECTION_REPORT_FOLLOW_UP_ADDED',
|
||||||
INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED',
|
INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED',
|
||||||
INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED',
|
INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED',
|
||||||
|
INSPECTION_DEADLINE_POLICY_UPDATED = 'INSPECTION_DEADLINE_POLICY_UPDATED',
|
||||||
|
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
|
||||||
|
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
|
||||||
|
SMTP_TEST_SENT = 'SMTP_TEST_SENT',
|
||||||
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
|
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
|
||||||
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
|
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
|
||||||
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
|
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
|
||||||
|
|||||||
@@ -6,14 +6,8 @@ export { Role } from './role.entity';
|
|||||||
export { UserRole } from './user-role.entity';
|
export { UserRole } from './user-role.entity';
|
||||||
export { User, UserStatus } from './user.entity';
|
export { User, UserStatus } from './user.entity';
|
||||||
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
|
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
|
||||||
export {
|
export { AssetVersion, AssetVersionChangeType } from './asset-version.entity';
|
||||||
AssetVersion,
|
export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity';
|
||||||
AssetVersionChangeType,
|
|
||||||
} from './asset-version.entity';
|
|
||||||
export {
|
|
||||||
AssetAttributeDataType,
|
|
||||||
AssetAttributeDefinition,
|
|
||||||
} from './asset-attribute-definition.entity';
|
|
||||||
export { AssetAttributeValue } from './asset-attribute-value.entity';
|
export { AssetAttributeValue } from './asset-attribute-value.entity';
|
||||||
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity';
|
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity';
|
||||||
export { OrganizationProfile, OrganizationKind } from './organization-profile.entity';
|
export { OrganizationProfile, OrganizationKind } from './organization-profile.entity';
|
||||||
@@ -25,69 +19,33 @@ export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area
|
|||||||
export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity';
|
export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity';
|
||||||
export { AssetTypeParentRule } from './asset-type-parent-rule.entity';
|
export { AssetTypeParentRule } from './asset-type-parent-rule.entity';
|
||||||
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
|
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
|
||||||
export {
|
export { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity';
|
||||||
AssetGeometry,
|
export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity';
|
||||||
AssetGeometrySource,
|
export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity';
|
||||||
AssetGeometryType,
|
|
||||||
} from './asset-geometry.entity';
|
|
||||||
export {
|
|
||||||
AssetMedia,
|
|
||||||
AssetMediaKind,
|
|
||||||
AssetMediaSource,
|
|
||||||
} from './asset-media.entity';
|
|
||||||
export {
|
|
||||||
SurveyCampaign,
|
|
||||||
SurveyCampaignStatus,
|
|
||||||
} from './survey-campaign.entity';
|
|
||||||
export {
|
|
||||||
SurveyCampaignTarget,
|
|
||||||
SurveyTargetStatus,
|
|
||||||
} from './survey-campaign-target.entity';
|
|
||||||
export {
|
|
||||||
SurveyReportOutcome,
|
|
||||||
SurveyReportStatus,
|
|
||||||
SurveyTargetReport,
|
|
||||||
} from './survey-target-report.entity';
|
|
||||||
export { SurveyTargetReportMedia } from './survey-target-report-media.entity';
|
|
||||||
export {
|
|
||||||
SurveyReportVersionEvent,
|
|
||||||
SurveyTargetReportVersion,
|
|
||||||
} from './survey-target-report-version.entity';
|
|
||||||
export {
|
|
||||||
InspectionVisit,
|
|
||||||
InspectionVisitStatus,
|
|
||||||
} from './inspection-visit.entity';
|
|
||||||
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
|
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
|
||||||
export { InspectionVisitMember } from './inspection-visit-member.entity';
|
export { InspectionVisitMember } from './inspection-visit-member.entity';
|
||||||
export {
|
export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
|
||||||
DocumentAnnualSequence,
|
|
||||||
DocumentSequenceType,
|
|
||||||
} from './document-annual-sequence.entity';
|
|
||||||
export {
|
export {
|
||||||
InspectionAct,
|
InspectionAct,
|
||||||
InspectionActStatus,
|
InspectionActStatus,
|
||||||
|
InspectionActUrgency,
|
||||||
|
InspectionDeadlineBasis,
|
||||||
|
InspectionDeadlineDayType,
|
||||||
} from './inspection-act.entity';
|
} from './inspection-act.entity';
|
||||||
export { InspectionActAsset } from './inspection-act-asset.entity';
|
export { InspectionActAsset } from './inspection-act-asset.entity';
|
||||||
export {
|
export {
|
||||||
InspectionReport,
|
InspectionReport,
|
||||||
InspectionReportPdfStatus,
|
InspectionReportPdfStatus,
|
||||||
InspectionReportReviewStatus,
|
|
||||||
InspectionReportStatus,
|
InspectionReportStatus,
|
||||||
InspectionReportWordStatus,
|
InspectionReportWordStatus,
|
||||||
} from './inspection-report.entity';
|
} from './inspection-report.entity';
|
||||||
export {
|
export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity';
|
||||||
InspectionActVersion,
|
|
||||||
InspectionActVersionEvent,
|
|
||||||
} from './inspection-act-version.entity';
|
|
||||||
export {
|
export {
|
||||||
InspectionActResponsible,
|
InspectionActResponsible,
|
||||||
InspectionResponsibleAttendanceStatus,
|
InspectionResponsibleAttendanceStatus,
|
||||||
InspectionResponsibleDocumentType,
|
InspectionResponsibleDocumentType,
|
||||||
} from './inspection-act-responsible.entity';
|
} from './inspection-act-responsible.entity';
|
||||||
export {
|
export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity';
|
||||||
InspectionActClosure,
|
|
||||||
InspectionActUploadMode,
|
|
||||||
} from './inspection-act-closure.entity';
|
|
||||||
export {
|
export {
|
||||||
InspectionActSignature,
|
InspectionActSignature,
|
||||||
InspectionActSignatureSource,
|
InspectionActSignatureSource,
|
||||||
@@ -95,21 +53,18 @@ export {
|
|||||||
InspectionActSignerType,
|
InspectionActSignerType,
|
||||||
InspectionCompanySignatureManifestation,
|
InspectionCompanySignatureManifestation,
|
||||||
} from './inspection-act-signature.entity';
|
} from './inspection-act-signature.entity';
|
||||||
|
export { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
|
||||||
|
export { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity';
|
||||||
|
export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity';
|
||||||
|
export { SystemSmtpSettings, SmtpSecurityMode } from './system-smtp-settings.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';
|
||||||
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
||||||
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
|
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
|
||||||
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
|
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
|
||||||
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
|
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
|
||||||
export {
|
export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity';
|
||||||
InspectionFinding,
|
export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity';
|
||||||
InspectionFindingResponseDueBasis,
|
|
||||||
InspectionFindingStatus,
|
|
||||||
} from './inspection-finding.entity';
|
|
||||||
export {
|
|
||||||
InspectionFindingVersion,
|
|
||||||
InspectionFindingVersionEvent,
|
|
||||||
} from './inspection-finding-version.entity';
|
|
||||||
export {
|
export {
|
||||||
InspectionCommunicationChannel,
|
InspectionCommunicationChannel,
|
||||||
InspectionCommunicationDirection,
|
InspectionCommunicationDirection,
|
||||||
@@ -122,14 +77,8 @@ export {
|
|||||||
InspectionEvidenceSource,
|
InspectionEvidenceSource,
|
||||||
InspectionFindingEvidence,
|
InspectionFindingEvidence,
|
||||||
} from './inspection-finding-evidence.entity';
|
} from './inspection-finding-evidence.entity';
|
||||||
export {
|
export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
|
||||||
InspectionFindingVerificationVisit,
|
export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity';
|
||||||
InspectionVerificationOutcome,
|
|
||||||
} from './inspection-finding-verification-visit.entity';
|
|
||||||
export {
|
|
||||||
InspectionFindingVerificationEvent,
|
|
||||||
InspectionFindingVerificationEventType,
|
|
||||||
} from './inspection-finding-verification-event.entity';
|
|
||||||
|
|
||||||
import { AuditEvent } from './audit-event.entity';
|
import { AuditEvent } from './audit-event.entity';
|
||||||
import { AuthSession } from './auth-session.entity';
|
import { AuthSession } from './auth-session.entity';
|
||||||
@@ -154,11 +103,6 @@ import { AreaLegalRightOrganization } from './area-legal-right-organization.enti
|
|||||||
import { AssetGeometry } from './asset-geometry.entity';
|
import { AssetGeometry } from './asset-geometry.entity';
|
||||||
import { AssetVersion } from './asset-version.entity';
|
import { AssetVersion } from './asset-version.entity';
|
||||||
import { AssetMedia } from './asset-media.entity';
|
import { AssetMedia } from './asset-media.entity';
|
||||||
import { SurveyCampaign } from './survey-campaign.entity';
|
|
||||||
import { SurveyCampaignTarget } from './survey-campaign-target.entity';
|
|
||||||
import { SurveyTargetReport } from './survey-target-report.entity';
|
|
||||||
import { SurveyTargetReportMedia } from './survey-target-report-media.entity';
|
|
||||||
import { SurveyTargetReportVersion } from './survey-target-report-version.entity';
|
|
||||||
import { InspectionVisit } from './inspection-visit.entity';
|
import { InspectionVisit } from './inspection-visit.entity';
|
||||||
import { InspectionVisitAsset } from './inspection-visit-asset.entity';
|
import { InspectionVisitAsset } from './inspection-visit-asset.entity';
|
||||||
import { InspectionVisitMember } from './inspection-visit-member.entity';
|
import { InspectionVisitMember } from './inspection-visit-member.entity';
|
||||||
@@ -170,6 +114,10 @@ import { InspectionActVersion } from './inspection-act-version.entity';
|
|||||||
import { InspectionActResponsible } from './inspection-act-responsible.entity';
|
import { InspectionActResponsible } from './inspection-act-responsible.entity';
|
||||||
import { InspectionActClosure } from './inspection-act-closure.entity';
|
import { InspectionActClosure } from './inspection-act-closure.entity';
|
||||||
import { InspectionActSignature } from './inspection-act-signature.entity';
|
import { InspectionActSignature } from './inspection-act-signature.entity';
|
||||||
|
import { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
|
||||||
|
import { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity';
|
||||||
|
import { InspectionReportFollowUp } from './inspection-report-follow-up.entity';
|
||||||
|
import { SystemSmtpSettings } from './system-smtp-settings.entity';
|
||||||
import { FindingCategory } from './finding-category.entity';
|
import { FindingCategory } from './finding-category.entity';
|
||||||
import { FindingCatalogItem } from './finding-catalog-item.entity';
|
import { FindingCatalogItem } from './finding-catalog-item.entity';
|
||||||
import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
||||||
@@ -207,11 +155,6 @@ export const PHASE_A_ENTITIES = [
|
|||||||
AssetGeometry,
|
AssetGeometry,
|
||||||
AssetVersion,
|
AssetVersion,
|
||||||
AssetMedia,
|
AssetMedia,
|
||||||
SurveyCampaign,
|
|
||||||
SurveyCampaignTarget,
|
|
||||||
SurveyTargetReport,
|
|
||||||
SurveyTargetReportMedia,
|
|
||||||
SurveyTargetReportVersion,
|
|
||||||
InspectionVisit,
|
InspectionVisit,
|
||||||
InspectionVisitAsset,
|
InspectionVisitAsset,
|
||||||
InspectionVisitMember,
|
InspectionVisitMember,
|
||||||
@@ -223,6 +166,10 @@ export const PHASE_A_ENTITIES = [
|
|||||||
InspectionActResponsible,
|
InspectionActResponsible,
|
||||||
InspectionActClosure,
|
InspectionActClosure,
|
||||||
InspectionActSignature,
|
InspectionActSignature,
|
||||||
|
InspectionDeadlinePolicy,
|
||||||
|
InspectionBusinessCalendarDay,
|
||||||
|
InspectionReportFollowUp,
|
||||||
|
SystemSmtpSettings,
|
||||||
FindingCategory,
|
FindingCategory,
|
||||||
FindingCatalogItem,
|
FindingCatalogItem,
|
||||||
FindingCatalogItemAssetType,
|
FindingCatalogItemAssetType,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|||||||
export enum InspectionActVersionEvent {
|
export enum InspectionActVersionEvent {
|
||||||
CREATED = 'CREATED',
|
CREATED = 'CREATED',
|
||||||
UPDATED = 'UPDATED',
|
UPDATED = 'UPDATED',
|
||||||
|
LOCKED = 'LOCKED',
|
||||||
|
SEALED = 'SEALED',
|
||||||
READY = 'READY',
|
READY = 'READY',
|
||||||
REOPENED = 'REOPENED',
|
REOPENED = 'REOPENED',
|
||||||
CLOSED = 'CLOSED',
|
CLOSED = 'CLOSED',
|
||||||
|
|||||||
@@ -3,12 +3,33 @@ import { TimestampedEntity } from './timestamped.entity';
|
|||||||
|
|
||||||
export enum InspectionActStatus {
|
export enum InspectionActStatus {
|
||||||
DRAFT = 'DRAFT',
|
DRAFT = 'DRAFT',
|
||||||
|
LOCKED = 'LOCKED',
|
||||||
|
SEALED = 'SEALED',
|
||||||
READY = 'READY',
|
READY = 'READY',
|
||||||
CLOSED = 'CLOSED',
|
CLOSED = 'CLOSED',
|
||||||
CANCELLED = 'CANCELLED',
|
CANCELLED = 'CANCELLED',
|
||||||
RECTIFIED = 'RECTIFIED',
|
RECTIFIED = 'RECTIFIED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum InspectionActUrgency {
|
||||||
|
URGENT = 'URGENT',
|
||||||
|
NON_URGENT = 'NON_URGENT',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InspectionDeadlineDayType {
|
||||||
|
BUSINESS = 'BUSINESS',
|
||||||
|
CALENDAR = 'CALENDAR',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InspectionDeadlineBasis {
|
||||||
|
ACT_DATE = 'ACT_DATE',
|
||||||
|
// La columna F4 temprana usó GEDO_DATE. Se conserva el valor físico por
|
||||||
|
// compatibilidad de migración, pero funcionalmente significa "pendiente de
|
||||||
|
// fecha de notificación" hasta que el procedimiento defina el evento válido.
|
||||||
|
NOTIFICATION_DATE = 'GEDO_DATE',
|
||||||
|
GEDO_DATE = 'GEDO_DATE',
|
||||||
|
}
|
||||||
|
|
||||||
@Entity({ name: 'inspection_acts' })
|
@Entity({ name: 'inspection_acts' })
|
||||||
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
|
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
|
||||||
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
|
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
|
||||||
@@ -16,6 +37,7 @@ export enum InspectionActStatus {
|
|||||||
@Index('idx_inspection_acts_visit_status', ['visitId', 'status'])
|
@Index('idx_inspection_acts_visit_status', ['visitId', 'status'])
|
||||||
@Index('idx_inspection_acts_occurred_at', ['occurredAt'])
|
@Index('idx_inspection_acts_occurred_at', ['occurredAt'])
|
||||||
@Index('idx_inspection_acts_created_by', ['createdBy'])
|
@Index('idx_inspection_acts_created_by', ['createdBy'])
|
||||||
|
@Index('idx_inspection_acts_deadline', ['deadlineAt'])
|
||||||
export class InspectionAct extends TimestampedEntity {
|
export class InspectionAct extends TimestampedEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id!: string;
|
||||||
@@ -29,7 +51,7 @@ export class InspectionAct extends TimestampedEntity {
|
|||||||
@Column({ name: 'act_number', type: 'integer' })
|
@Column({ name: 'act_number', type: 'integer' })
|
||||||
actNumber!: number;
|
actNumber!: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 24 })
|
@Column({ type: 'varchar', length: 40 })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT })
|
@Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT })
|
||||||
@@ -47,6 +69,39 @@ export class InspectionAct extends TimestampedEntity {
|
|||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
observations!: string | null;
|
observations!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT })
|
||||||
|
urgency!: InspectionActUrgency;
|
||||||
|
|
||||||
|
@Column({ name: 'deadline_days', type: 'integer', nullable: true })
|
||||||
|
deadlineDays!: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'deadline_day_type', type: 'varchar', length: 24, nullable: true })
|
||||||
|
deadlineDayType!: InspectionDeadlineDayType | null;
|
||||||
|
|
||||||
|
@Column({ name: 'deadline_basis', type: 'varchar', length: 24, nullable: true })
|
||||||
|
deadlineBasis!: InspectionDeadlineBasis | null;
|
||||||
|
|
||||||
|
@Column({ name: 'deadline_base_at', type: 'timestamptz', nullable: true })
|
||||||
|
deadlineBaseAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'deadline_at', type: 'timestamptz', nullable: true })
|
||||||
|
deadlineAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
|
||||||
|
lockedAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'locked_by', type: 'uuid', nullable: true })
|
||||||
|
lockedBy!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'locked_sha256', type: 'char', length: 64, nullable: true })
|
||||||
|
lockedSha256!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'sealed_at', type: 'timestamptz', nullable: true })
|
||||||
|
sealedAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'sealed_by', type: 'uuid', nullable: true })
|
||||||
|
sealedBy!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'current_version', type: 'integer', default: 0 })
|
@Column({ name: 'current_version', type: 'integer', default: 0 })
|
||||||
currentVersion!: number;
|
currentVersion!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { TimestampedEntity } from './timestamped.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'inspection_business_calendar_days' })
|
||||||
|
@Index('uq_inspection_business_calendar_day', ['date'], { unique: true })
|
||||||
|
export class InspectionBusinessCalendarDay extends TimestampedEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'date' })
|
||||||
|
date!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_business_day', type: 'boolean', default: false })
|
||||||
|
isBusinessDay!: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 200 })
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy!: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { InspectionDeadlineDayType } from './inspection-act.entity';
|
||||||
|
import { TimestampedEntity } from './timestamped.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'inspection_deadline_policies' })
|
||||||
|
export class InspectionDeadlinePolicy extends TimestampedEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'urgent_days', type: 'integer', default: 5 })
|
||||||
|
urgentDays!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS })
|
||||||
|
urgentDayType!: InspectionDeadlineDayType;
|
||||||
|
|
||||||
|
@Column({ name: 'non_urgent_days', type: 'integer', default: 10 })
|
||||||
|
nonUrgentDays!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'non_urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS })
|
||||||
|
nonUrgentDayType!: InspectionDeadlineDayType;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy!: string | null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
import { TimestampedEntity } from './timestamped.entity';
|
import { TimestampedEntity } from './timestamped.entity';
|
||||||
|
|
||||||
|
/** @deprecated Los plazos de respuesta pasan al nivel Acta/Informe en F4. */
|
||||||
export enum InspectionFindingResponseDueBasis {
|
export enum InspectionFindingResponseDueBasis {
|
||||||
FINDING_DATE = 'FINDING_DATE',
|
FINDING_DATE = 'FINDING_DATE',
|
||||||
REPORT_NOTIFICATION = 'REPORT_NOTIFICATION',
|
REPORT_NOTIFICATION = 'REPORT_NOTIFICATION',
|
||||||
@@ -18,6 +19,7 @@ export enum InspectionFindingStatus {
|
|||||||
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
|
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
|
||||||
@Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
|
@Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
|
||||||
@Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
|
@Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
|
||||||
|
@Index('idx_inspection_findings_recurrence_of', ['recurrenceOfFindingId'])
|
||||||
export class InspectionFinding extends TimestampedEntity {
|
export class InspectionFinding extends TimestampedEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id!: string;
|
||||||
@@ -61,9 +63,16 @@ export class InspectionFinding extends TimestampedEntity {
|
|||||||
@Column({ type: 'smallint', nullable: true })
|
@Column({ type: 'smallint', nullable: true })
|
||||||
severity!: number | null;
|
severity!: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'is_recurrence', type: 'boolean', default: false })
|
||||||
|
isRecurrence!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'recurrence_of_finding_id', type: 'uuid', nullable: true })
|
||||||
|
recurrenceOfFindingId!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'correction_due_on', type: 'date', nullable: true })
|
@Column({ name: 'correction_due_on', type: 'date', nullable: true })
|
||||||
correctionDueOn!: string | null;
|
correctionDueOn!: string | null;
|
||||||
|
|
||||||
|
// Campos legacy conservados temporalmente para migrar datos sin pérdida.
|
||||||
@Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true })
|
@Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true })
|
||||||
responseDueBasis!: InspectionFindingResponseDueBasis | null;
|
responseDueBasis!: InspectionFindingResponseDueBasis | null;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { TimestampedEntity } from './timestamped.entity';
|
||||||
|
|
||||||
|
export enum InspectionReportFollowUpType {
|
||||||
|
COMPANY_NOTE = 'COMPANY_NOTE',
|
||||||
|
COMPANY_DOCUMENT = 'COMPANY_DOCUMENT',
|
||||||
|
INTERNAL_NOTE = 'INTERNAL_NOTE',
|
||||||
|
VERIFICATION = 'VERIFICATION',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ name: 'inspection_report_follow_ups' })
|
||||||
|
@Index('idx_inspection_report_follow_ups_report_created', ['reportId', 'createdAt'])
|
||||||
|
export class InspectionReportFollowUp extends TimestampedEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'report_id', type: 'uuid' })
|
||||||
|
reportId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 40 })
|
||||||
|
type!: InspectionReportFollowUpType;
|
||||||
|
|
||||||
|
@Column({ name: 'external_reference', type: 'varchar', length: 255, nullable: true })
|
||||||
|
externalReference!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||||
|
occurredAt!: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true })
|
||||||
|
originalName!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'stored_name', type: 'varchar', length: 255, nullable: true })
|
||||||
|
storedName!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'mime_type', type: 'varchar', length: 120, nullable: true })
|
||||||
|
mimeType!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'size_bytes', type: 'integer', nullable: true })
|
||||||
|
sizeBytes!: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'sha256', type: 'char', length: 64, nullable: true })
|
||||||
|
sha256!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||||
|
createdBy!: string | null;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|||||||
import { TimestampedEntity } from './timestamped.entity';
|
import { TimestampedEntity } from './timestamped.entity';
|
||||||
|
|
||||||
export enum InspectionReportStatus {
|
export enum InspectionReportStatus {
|
||||||
|
WORKING = 'WORKING',
|
||||||
|
OFFICIALIZED = 'OFFICIALIZED',
|
||||||
FROZEN = 'FROZEN',
|
FROZEN = 'FROZEN',
|
||||||
CANCELLED = 'CANCELLED',
|
CANCELLED = 'CANCELLED',
|
||||||
}
|
}
|
||||||
@@ -18,12 +20,6 @@ export enum InspectionReportWordStatus {
|
|||||||
FAILED = 'FAILED',
|
FAILED = 'FAILED',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum InspectionReportReviewStatus {
|
|
||||||
PENDING_REVIEW = 'PENDING_REVIEW',
|
|
||||||
APPROVED = 'APPROVED',
|
|
||||||
SIGNED = 'SIGNED',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity({ name: 'inspection_reports' })
|
@Entity({ name: 'inspection_reports' })
|
||||||
@Index('idx_inspection_reports_visit_id', ['visitId'])
|
@Index('idx_inspection_reports_visit_id', ['visitId'])
|
||||||
@Index('uq_inspection_reports_act', ['actId'], { unique: true })
|
@Index('uq_inspection_reports_act', ['actId'], { unique: true })
|
||||||
@@ -31,6 +27,7 @@ export enum InspectionReportReviewStatus {
|
|||||||
@Index('uq_inspection_reports_code', ['code'], { unique: true })
|
@Index('uq_inspection_reports_code', ['code'], { unique: true })
|
||||||
@Index('idx_inspection_reports_generated_at', ['generatedAt'])
|
@Index('idx_inspection_reports_generated_at', ['generatedAt'])
|
||||||
@Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
|
@Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
|
||||||
|
@Index('idx_inspection_reports_gedo_officialized_at', ['gedoOfficializedAt'])
|
||||||
export class InspectionReport extends TimestampedEntity {
|
export class InspectionReport extends TimestampedEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id!: string;
|
id!: string;
|
||||||
@@ -47,12 +44,39 @@ export class InspectionReport extends TimestampedEntity {
|
|||||||
@Column({ name: 'report_number', type: 'integer' })
|
@Column({ name: 'report_number', type: 'integer' })
|
||||||
reportNumber!: number;
|
reportNumber!: number;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 24 })
|
@Column({ type: 'varchar', length: 40 })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN })
|
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.WORKING })
|
||||||
status!: InspectionReportStatus;
|
status!: InspectionReportStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'executive_summary', type: 'text', nullable: true })
|
||||||
|
executiveSummary!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'report_description', type: 'text', nullable: true })
|
||||||
|
reportDescription!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'gedo_if_identifier', type: 'varchar', length: 255, nullable: true })
|
||||||
|
gedoIfIdentifier!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'gedo_officialized_at', type: 'timestamptz', nullable: true })
|
||||||
|
gedoOfficializedAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'gedo_pdf_original_name', type: 'varchar', length: 255, nullable: true })
|
||||||
|
gedoPdfOriginalName!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'gedo_pdf_stored_name', type: 'varchar', length: 255, nullable: true })
|
||||||
|
gedoPdfStoredName!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'gedo_pdf_mime_type', type: 'varchar', length: 120, nullable: true })
|
||||||
|
gedoPdfMimeType!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'gedo_pdf_size_bytes', type: 'integer', nullable: true })
|
||||||
|
gedoPdfSizeBytes!: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'gedo_pdf_sha256', type: 'char', length: 64, nullable: true })
|
||||||
|
gedoPdfSha256!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING })
|
@Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING })
|
||||||
pdfStatus!: InspectionReportPdfStatus;
|
pdfStatus!: InspectionReportPdfStatus;
|
||||||
|
|
||||||
@@ -80,27 +104,9 @@ export class InspectionReport extends TimestampedEntity {
|
|||||||
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
|
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
|
||||||
wordError!: string | null;
|
wordError!: string | null;
|
||||||
|
|
||||||
@Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW })
|
|
||||||
reviewStatus!: InspectionReportReviewStatus;
|
|
||||||
|
|
||||||
@Column({ name: 'current_revision_number', type: 'integer', default: 0 })
|
@Column({ name: 'current_revision_number', type: 'integer', default: 0 })
|
||||||
currentRevisionNumber!: number;
|
currentRevisionNumber!: number;
|
||||||
|
|
||||||
@Column({ name: 'approved_revision_id', type: 'uuid', nullable: true })
|
|
||||||
approvedRevisionId!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
|
|
||||||
approvedBy!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
|
||||||
approvedAt!: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'review_note', type: 'varchar', length: 1000, nullable: true })
|
|
||||||
reviewNote!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
|
|
||||||
signedAt!: Date | null;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 220 })
|
@Column({ type: 'varchar', length: 220 })
|
||||||
title!: string;
|
title!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export enum InspectionVisitStatus {
|
|||||||
@Index('idx_inspection_visits_status', ['status'])
|
@Index('idx_inspection_visits_status', ['status'])
|
||||||
@Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId'])
|
@Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId'])
|
||||||
@Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId'])
|
@Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId'])
|
||||||
@Index('idx_inspection_visits_planned_dates', ['plannedStartAt', 'plannedEndAt'])
|
@Index('idx_inspection_visits_planned_start_at', ['plannedStartAt'])
|
||||||
@Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId'])
|
@Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId'])
|
||||||
export class InspectionVisit extends TimestampedEntity {
|
export class InspectionVisit extends TimestampedEntity {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
@@ -23,9 +23,6 @@ export class InspectionVisit extends TimestampedEntity {
|
|||||||
@Column({ type: 'varchar', length: 80 })
|
@Column({ type: 'varchar', length: 80 })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 200 })
|
|
||||||
title!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
objective!: string | null;
|
objective!: string | null;
|
||||||
|
|
||||||
@@ -47,9 +44,6 @@ export class InspectionVisit extends TimestampedEntity {
|
|||||||
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
|
||||||
plannedStartAt!: Date | null;
|
plannedStartAt!: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
|
|
||||||
plannedEndAt!: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true })
|
||||||
actualStartedAt!: Date | null;
|
actualStartedAt!: Date | null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|
||||||
import { TimestampedEntity } from './timestamped.entity';
|
|
||||||
|
|
||||||
export enum SurveyTargetStatus {
|
|
||||||
PENDING = 'PENDING',
|
|
||||||
IN_PROGRESS = 'IN_PROGRESS',
|
|
||||||
SUBMITTED = 'SUBMITTED',
|
|
||||||
COMPLETED = 'COMPLETED',
|
|
||||||
SKIPPED = 'SKIPPED',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity({ name: 'survey_campaign_targets' })
|
|
||||||
@Index('uq_survey_campaign_targets_campaign_asset', ['campaignId', 'assetId'], { unique: true })
|
|
||||||
@Index('idx_survey_campaign_targets_campaign_status', ['campaignId', 'status'])
|
|
||||||
@Index('idx_survey_campaign_targets_asset_id', ['assetId'])
|
|
||||||
@Index('idx_survey_campaign_targets_assigned_user_id', ['assignedUserId'])
|
|
||||||
export class SurveyCampaignTarget extends TimestampedEntity {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'campaign_id', type: 'uuid' })
|
|
||||||
campaignId!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'asset_id', type: 'uuid' })
|
|
||||||
assetId!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'assigned_user_id', type: 'uuid', nullable: true })
|
|
||||||
assignedUserId!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 24, default: SurveyTargetStatus.PENDING })
|
|
||||||
status!: SurveyTargetStatus;
|
|
||||||
|
|
||||||
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
|
|
||||||
dueAt!: Date | null;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
instructions!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
||||||
createdBy!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
||||||
updatedBy!: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|
||||||
import { TimestampedEntity } from './timestamped.entity';
|
|
||||||
|
|
||||||
export enum SurveyCampaignStatus {
|
|
||||||
DRAFT = 'DRAFT',
|
|
||||||
PLANNED = 'PLANNED',
|
|
||||||
IN_PROGRESS = 'IN_PROGRESS',
|
|
||||||
COMPLETED = 'COMPLETED',
|
|
||||||
CANCELLED = 'CANCELLED',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity({ name: 'survey_campaigns' })
|
|
||||||
@Index('uq_survey_campaigns_code', ['code'], { unique: true })
|
|
||||||
@Index('idx_survey_campaigns_status', ['status'])
|
|
||||||
@Index('idx_survey_campaigns_scope_asset_id', ['scopeAssetId'])
|
|
||||||
@Index('idx_survey_campaigns_coordinator_user_id', ['coordinatorUserId'])
|
|
||||||
@Index('idx_survey_campaigns_planned_dates', ['plannedStartAt', 'plannedEndAt'])
|
|
||||||
export class SurveyCampaign extends TimestampedEntity {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 80 })
|
|
||||||
code!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 200 })
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
description!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 24, default: SurveyCampaignStatus.DRAFT })
|
|
||||||
status!: SurveyCampaignStatus;
|
|
||||||
|
|
||||||
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
|
|
||||||
plannedStartAt!: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
|
|
||||||
plannedEndAt!: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'scope_asset_id', type: 'uuid', nullable: true })
|
|
||||||
scopeAssetId!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'coordinator_user_id', type: 'uuid', nullable: true })
|
|
||||||
coordinatorUserId!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
||||||
createdBy!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
||||||
updatedBy!: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
|
||||||
import { TimestampedEntity } from './timestamped.entity';
|
|
||||||
|
|
||||||
@Entity({ name: 'survey_target_report_media' })
|
|
||||||
@Index('idx_survey_target_report_media_media_id', ['mediaId'])
|
|
||||||
@Index('idx_survey_target_report_media_included', ['reportId', 'included'])
|
|
||||||
export class SurveyTargetReportMedia extends TimestampedEntity {
|
|
||||||
@PrimaryColumn({ name: 'report_id', type: 'uuid' })
|
|
||||||
reportId!: string;
|
|
||||||
|
|
||||||
@PrimaryColumn({ name: 'media_id', type: 'uuid' })
|
|
||||||
mediaId!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: true })
|
|
||||||
included!: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'added_by', type: 'uuid', nullable: true })
|
|
||||||
addedBy!: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|
||||||
|
|
||||||
export enum SurveyReportVersionEvent {
|
|
||||||
SUBMITTED = 'SUBMITTED',
|
|
||||||
APPROVED = 'APPROVED',
|
|
||||||
REJECTED = 'REJECTED',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity({ name: 'survey_target_report_versions' })
|
|
||||||
@Index('uq_survey_target_report_versions_number', ['reportId', 'versionNumber'], { unique: true })
|
|
||||||
@Index('idx_survey_target_report_versions_created_at', ['createdAt'])
|
|
||||||
export class SurveyTargetReportVersion {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'report_id', type: 'uuid' })
|
|
||||||
reportId!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'version_number', type: 'integer' })
|
|
||||||
versionNumber!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 24 })
|
|
||||||
event!: SurveyReportVersionEvent;
|
|
||||||
|
|
||||||
@Column({ type: 'jsonb' })
|
|
||||||
snapshot!: Record<string, unknown>;
|
|
||||||
|
|
||||||
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
|
|
||||||
actorUserId!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
|
|
||||||
actorUsername!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
|
||||||
createdAt!: Date;
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
|
||||||
import { TimestampedEntity } from './timestamped.entity';
|
|
||||||
|
|
||||||
export enum SurveyReportOutcome {
|
|
||||||
CONFIRMED = 'CONFIRMED',
|
|
||||||
CHANGES_RECORDED = 'CHANGES_RECORDED',
|
|
||||||
NOT_LOCATED = 'NOT_LOCATED',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SurveyReportStatus {
|
|
||||||
DRAFT = 'DRAFT',
|
|
||||||
SUBMITTED = 'SUBMITTED',
|
|
||||||
APPROVED = 'APPROVED',
|
|
||||||
REJECTED = 'REJECTED',
|
|
||||||
}
|
|
||||||
|
|
||||||
@Entity({ name: 'survey_target_reports' })
|
|
||||||
@Index('uq_survey_target_reports_target_id', ['targetId'], { unique: true })
|
|
||||||
@Index('idx_survey_target_reports_status', ['status'])
|
|
||||||
@Index('idx_survey_target_reports_submitted_by', ['submittedBy'])
|
|
||||||
@Index('idx_survey_target_reports_reviewed_by', ['reviewedBy'])
|
|
||||||
export class SurveyTargetReport extends TimestampedEntity {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ name: 'target_id', type: 'uuid' })
|
|
||||||
targetId!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 32, nullable: true })
|
|
||||||
outcome!: SurveyReportOutcome | null;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 24, default: SurveyReportStatus.DRAFT })
|
|
||||||
status!: SurveyReportStatus;
|
|
||||||
|
|
||||||
@Column({ name: 'observed_at', type: 'timestamptz', nullable: true })
|
|
||||||
observedAt!: Date | null;
|
|
||||||
|
|
||||||
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
|
|
||||||
latitude!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
|
|
||||||
longitude!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
|
||||||
accuracyM!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
|
||||||
notes!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'asset_version_at_submission', type: 'integer', nullable: true })
|
|
||||||
assetVersionAtSubmission!: number | null;
|
|
||||||
|
|
||||||
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
|
|
||||||
submittedAt!: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
|
|
||||||
submittedBy!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
|
||||||
reviewedAt!: Date | null;
|
|
||||||
|
|
||||||
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
|
|
||||||
reviewedBy!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'review_notes', type: 'text', nullable: true })
|
|
||||||
reviewNotes!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
||||||
createdBy!: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
||||||
updatedBy!: string | null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { TimestampedEntity } from './timestamped.entity';
|
||||||
|
|
||||||
|
export enum SmtpSecurityMode {
|
||||||
|
NONE = 'NONE',
|
||||||
|
STARTTLS = 'STARTTLS',
|
||||||
|
TLS = 'TLS',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ name: 'system_smtp_settings' })
|
||||||
|
export class SystemSmtpSettings extends TimestampedEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255 })
|
||||||
|
host!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer' })
|
||||||
|
port!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'security_mode', type: 'varchar', length: 24, default: SmtpSecurityMode.STARTTLS })
|
||||||
|
securityMode!: SmtpSecurityMode;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
username!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'password_enc', type: 'text', nullable: true })
|
||||||
|
passwordEnc!: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'from_name', type: 'varchar', length: 200 })
|
||||||
|
fromName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'from_email', type: 'varchar', length: 320 })
|
||||||
|
fromEmail!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'reply_to', type: 'varchar', length: 320, nullable: true })
|
||||||
|
replyTo!: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
enabled!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
||||||
|
updatedBy!: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F4DocumentWorkflowFoundation1790000000000 implements MigrationInterface {
|
||||||
|
name = 'F4DocumentWorkflowFoundation1790000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ALTER COLUMN code TYPE varchar(40)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ALTER COLUMN code TYPE varchar(40)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS urgency varchar(24) NOT NULL DEFAULT 'NON_URGENT'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_days integer`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_day_type varchar(24)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_basis varchar(24)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_base_at timestamptz`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_at timestamptz`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_at timestamptz`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_by uuid`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_sha256 char(64)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_at timestamptz`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_by uuid`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_acts_deadline ON inspection_acts(deadline_at)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS is_recurrence boolean NOT NULL DEFAULT false`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS recurrence_of_finding_id uuid`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_findings_recurrence_of ON inspection_findings(recurrence_of_finding_id)`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE inspection_findings
|
||||||
|
ADD CONSTRAINT fk_inspection_findings_recurrence_of
|
||||||
|
FOREIGN KEY (recurrence_of_finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS executive_summary text`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS report_description text`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_if_identifier varchar(255)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_officialized_at timestamptz`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_original_name varchar(255)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_stored_name varchar(255)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_mime_type varchar(120)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_size_bytes integer`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_sha256 char(64)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_reports_gedo_officialized_at ON inspection_reports(gedo_officialized_at)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS inspection_deadline_policies (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
urgent_days integer NOT NULL DEFAULT 5 CHECK (urgent_days > 0),
|
||||||
|
urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS',
|
||||||
|
non_urgent_days integer NOT NULL DEFAULT 10 CHECK (non_urgent_days > 0),
|
||||||
|
non_urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS',
|
||||||
|
updated_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ck_inspection_deadline_policy_urgent_type CHECK (urgent_day_type IN ('BUSINESS','CALENDAR')),
|
||||||
|
CONSTRAINT ck_inspection_deadline_policy_non_urgent_type CHECK (non_urgent_day_type IN ('BUSINESS','CALENDAR'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inspection_deadline_policies (urgent_days, urgent_day_type, non_urgent_days, non_urgent_day_type)
|
||||||
|
SELECT 5, 'BUSINESS', 10, 'BUSINESS'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM inspection_deadline_policies)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS inspection_business_calendar_days (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
date date NOT NULL,
|
||||||
|
is_business_day boolean NOT NULL DEFAULT false,
|
||||||
|
label varchar(200) NOT NULL,
|
||||||
|
updated_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_inspection_business_calendar_day UNIQUE(date)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS inspection_report_follow_ups (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
report_id uuid NOT NULL,
|
||||||
|
type varchar(40) NOT NULL,
|
||||||
|
external_reference varchar(255),
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
description text,
|
||||||
|
original_name varchar(255),
|
||||||
|
stored_name varchar(255),
|
||||||
|
mime_type varchar(120),
|
||||||
|
size_bytes integer,
|
||||||
|
sha256 char(64),
|
||||||
|
created_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT fk_inspection_report_follow_ups_report FOREIGN KEY(report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT ck_inspection_report_follow_up_type CHECK (type IN ('COMPANY_NOTE','COMPANY_DOCUMENT','INTERNAL_NOTE','VERIFICATION','OTHER'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_report_follow_ups_report_created ON inspection_report_follow_ups(report_id, created_at)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS system_smtp_settings (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
host varchar(255) NOT NULL,
|
||||||
|
port integer NOT NULL CHECK (port > 0 AND port <= 65535),
|
||||||
|
security_mode varchar(24) NOT NULL DEFAULT 'STARTTLS',
|
||||||
|
username varchar(255),
|
||||||
|
password_enc text,
|
||||||
|
from_name varchar(200) NOT NULL,
|
||||||
|
from_email varchar(320) NOT NULL,
|
||||||
|
reply_to varchar(320),
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
updated_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ck_system_smtp_security_mode CHECK (security_mode IN ('NONE','STARTTLS','TLS'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inspection_acts
|
||||||
|
SET
|
||||||
|
deadline_days = COALESCE(deadline_days, 10),
|
||||||
|
deadline_day_type = COALESCE(deadline_day_type, 'BUSINESS'),
|
||||||
|
deadline_basis = COALESCE(deadline_basis, 'GEDO_DATE')
|
||||||
|
WHERE deadline_days IS NULL OR deadline_day_type IS NULL OR deadline_basis IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS system_smtp_settings`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_ups`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS inspection_business_calendar_days`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS inspection_deadline_policies`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_sha256`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_size_bytes`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_mime_type`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_stored_name`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_original_name`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_officialized_at`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_if_identifier`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS report_description`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS executive_summary`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_findings DROP CONSTRAINT IF EXISTS fk_inspection_findings_recurrence_of`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS recurrence_of_finding_id`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS is_recurrence`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_by`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_at`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_sha256`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_by`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_at`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_at`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_base_at`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_basis`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_day_type`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_days`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS urgency`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F4InventoryFunctionHistory1790000100000 implements MigrationInterface {
|
||||||
|
name = 'F4InventoryFunctionHistory1790000100000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE inventory_functions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code varchar(120) NOT NULL UNIQUE,
|
||||||
|
name varchar(240) NOT NULL,
|
||||||
|
description text,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
created_by uuid,
|
||||||
|
updated_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_inventory_functions_created_by FOREIGN KEY (created_by)
|
||||||
|
REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_inventory_functions_updated_by FOREIGN KEY (updated_by)
|
||||||
|
REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_inventory_functions_active_sort
|
||||||
|
ON inventory_functions(is_active,sort_order,name)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE inventory_function_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
asset_id uuid NOT NULL,
|
||||||
|
function_id uuid NOT NULL,
|
||||||
|
valid_from timestamptz NOT NULL,
|
||||||
|
valid_until timestamptz,
|
||||||
|
change_reason text,
|
||||||
|
changed_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_inventory_function_assignment_asset FOREIGN KEY (asset_id)
|
||||||
|
REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_inventory_function_assignment_function FOREIGN KEY (function_id)
|
||||||
|
REFERENCES inventory_functions(id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_inventory_function_assignment_user FOREIGN KEY (changed_by)
|
||||||
|
REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT chk_inventory_function_assignment_dates CHECK (
|
||||||
|
valid_until IS NULL OR valid_until > valid_from
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX uq_inventory_function_assignment_current
|
||||||
|
ON inventory_function_assignments(asset_id)
|
||||||
|
WHERE valid_until IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_inventory_function_assignment_history
|
||||||
|
ON inventory_function_assignments(asset_id,valid_from DESC,created_at DESC)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_inventory_function_assignment_function
|
||||||
|
ON inventory_function_assignments(function_id,valid_from DESC)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_functions(code,name,description,sort_order)
|
||||||
|
VALUES
|
||||||
|
('BOMBEO_MECANICO_AIB','Bombeo mecánico AIB',
|
||||||
|
'Función técnica de bombeo mecánico mediante aparato individual de bombeo (AIB).',10),
|
||||||
|
('BOMBEO_MECANICO_ROTAFLEX','Bombeo mecánico Rotaflex',
|
||||||
|
'Función técnica de bombeo mecánico mediante sistema Rotaflex.',20)
|
||||||
|
ON CONFLICT (code) DO NOTHING
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TABLE IF EXISTS inventory_function_assignments');
|
||||||
|
await queryRunner.query('DROP TABLE IF EXISTS inventory_functions');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F4CompanySignatureInvites1790000200000 implements MigrationInterface {
|
||||||
|
name = 'F4CompanySignatureInvites1790000200000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE inspection_act_company_signature_invites (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
act_id uuid NOT NULL,
|
||||||
|
token_sha256 char(64) NOT NULL UNIQUE,
|
||||||
|
recipient_email varchar(320) NOT NULL,
|
||||||
|
recipient_name varchar(200),
|
||||||
|
recipient_document_type varchar(20),
|
||||||
|
recipient_document_number varchar(40),
|
||||||
|
recipient_position varchar(200),
|
||||||
|
status varchar(20) NOT NULL DEFAULT 'PENDING',
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
sent_at timestamptz,
|
||||||
|
used_at timestamptz,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
created_by uuid NOT NULL,
|
||||||
|
revoked_by uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_company_signature_invite_act FOREIGN KEY (act_id)
|
||||||
|
REFERENCES inspection_acts(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_company_signature_invite_created_by FOREIGN KEY (created_by)
|
||||||
|
REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_company_signature_invite_revoked_by FOREIGN KEY (revoked_by)
|
||||||
|
REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT chk_company_signature_invite_status CHECK (
|
||||||
|
status IN ('PENDING','USED','REVOKED','EXPIRED')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_company_signature_invites_act_created
|
||||||
|
ON inspection_act_company_signature_invites(act_id,created_at DESC)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_company_signature_invites_pending_expiry
|
||||||
|
ON inspection_act_company_signature_invites(status,expires_at)
|
||||||
|
WHERE status='PENDING'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TABLE IF EXISTS inspection_act_company_signature_invites');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
const surveyPermissions = [
|
||||||
|
'surveys.read',
|
||||||
|
'surveys.manage',
|
||||||
|
'surveys.assign',
|
||||||
|
'surveys.execute',
|
||||||
|
'surveys.read_reports',
|
||||||
|
'surveys.capture',
|
||||||
|
'surveys.review',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const rolePermissionValues = `
|
||||||
|
('admin', 'surveys.read'),
|
||||||
|
('admin', 'surveys.manage'),
|
||||||
|
('admin', 'surveys.assign'),
|
||||||
|
('admin', 'surveys.execute'),
|
||||||
|
('admin', 'surveys.read_reports'),
|
||||||
|
('admin', 'surveys.capture'),
|
||||||
|
('admin', 'surveys.review'),
|
||||||
|
('director', 'surveys.read'),
|
||||||
|
('director', 'surveys.read_reports'),
|
||||||
|
('director', 'surveys.review'),
|
||||||
|
('supervisor', 'surveys.read'),
|
||||||
|
('supervisor', 'surveys.manage'),
|
||||||
|
('supervisor', 'surveys.assign'),
|
||||||
|
('supervisor', 'surveys.execute'),
|
||||||
|
('supervisor', 'surveys.read_reports'),
|
||||||
|
('supervisor', 'surveys.capture'),
|
||||||
|
('supervisor', 'surveys.review'),
|
||||||
|
('inspector', 'surveys.read'),
|
||||||
|
('inspector', 'surveys.execute'),
|
||||||
|
('inspector', 'surveys.read_reports'),
|
||||||
|
('inspector', 'surveys.capture'),
|
||||||
|
('auditor', 'surveys.read'),
|
||||||
|
('auditor', 'surveys.read_reports')
|
||||||
|
`;
|
||||||
|
|
||||||
|
function quoteIdentifier(identifier: string): string {
|
||||||
|
return `"${identifier.replaceAll('"', '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class F4RemoveSurveySubsystem1790000300000 implements MigrationInterface {
|
||||||
|
name = 'F4RemoveSurveySubsystem1790000300000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id IN (
|
||||||
|
SELECT id FROM permissions WHERE code = ANY($1::varchar[])
|
||||||
|
)
|
||||||
|
`, [surveyPermissions]);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM permissions WHERE code = ANY($1::varchar[])`,
|
||||||
|
[surveyPermissions],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deliberadamente sin CASCADE: una dependencia externa debe detener la migración.
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_versions`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_media`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_reports`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaign_targets`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaigns`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_campaigns (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code varchar(80) NOT NULL UNIQUE,
|
||||||
|
name varchar(200) NOT NULL,
|
||||||
|
description text,
|
||||||
|
status varchar(24) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
planned_start_at timestamptz,
|
||||||
|
planned_end_at timestamptz,
|
||||||
|
scope_asset_id uuid REFERENCES assets(id) ON DELETE RESTRICT,
|
||||||
|
coordinator_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_survey_campaigns_status CHECK (
|
||||||
|
status IN ('DRAFT','PLANNED','IN_PROGRESS','COMPLETED','CANCELLED')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_survey_campaigns_dates CHECK (
|
||||||
|
planned_start_at IS NULL OR planned_end_at IS NULL OR planned_end_at >= planned_start_at
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_status ON survey_campaigns(status)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_scope_asset_id ON survey_campaigns(scope_asset_id)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_coordinator_user_id ON survey_campaigns(coordinator_user_id)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_planned_dates ON survey_campaigns(planned_start_at,planned_end_at)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_campaign_targets (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
campaign_id uuid NOT NULL REFERENCES survey_campaigns(id) ON DELETE RESTRICT,
|
||||||
|
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
|
||||||
|
assigned_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
status varchar(24) NOT NULL DEFAULT 'PENDING',
|
||||||
|
due_at timestamptz,
|
||||||
|
instructions text,
|
||||||
|
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_survey_campaign_targets_campaign_asset UNIQUE(campaign_id,asset_id),
|
||||||
|
CONSTRAINT chk_survey_campaign_targets_status CHECK (
|
||||||
|
status IN ('PENDING','IN_PROGRESS','SUBMITTED','COMPLETED','SKIPPED')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_campaign_status ON survey_campaign_targets(campaign_id,status)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_asset_id ON survey_campaign_targets(asset_id)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_assigned_user_id ON survey_campaign_targets(assigned_user_id)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_target_reports (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
target_id uuid NOT NULL UNIQUE REFERENCES survey_campaign_targets(id) ON DELETE RESTRICT,
|
||||||
|
outcome varchar(32),
|
||||||
|
status varchar(24) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
observed_at timestamptz,
|
||||||
|
latitude numeric(9,6),
|
||||||
|
longitude numeric(9,6),
|
||||||
|
accuracy_m numeric(12,3),
|
||||||
|
notes text,
|
||||||
|
asset_version_at_submission integer,
|
||||||
|
submitted_at timestamptz,
|
||||||
|
submitted_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
reviewed_at timestamptz,
|
||||||
|
reviewed_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
review_notes text,
|
||||||
|
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_survey_target_reports_outcome CHECK (
|
||||||
|
outcome IS NULL OR outcome IN ('CONFIRMED','CHANGES_RECORDED','NOT_LOCATED')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_survey_target_reports_status CHECK (
|
||||||
|
status IN ('DRAFT','SUBMITTED','APPROVED','REJECTED')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_survey_target_reports_coordinates CHECK (
|
||||||
|
(latitude IS NULL AND longitude IS NULL)
|
||||||
|
OR (latitude IS NOT NULL AND longitude IS NOT NULL
|
||||||
|
AND latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180)
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_survey_target_reports_accuracy CHECK (
|
||||||
|
accuracy_m IS NULL OR (accuracy_m >= 0 AND latitude IS NOT NULL AND longitude IS NOT NULL)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_status ON survey_target_reports(status)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_submitted_by ON survey_target_reports(submitted_by)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_reviewed_by ON survey_target_reports(reviewed_by)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_target_report_media (
|
||||||
|
report_id uuid NOT NULL REFERENCES survey_target_reports(id) ON DELETE RESTRICT,
|
||||||
|
media_id uuid NOT NULL REFERENCES asset_media(id) ON DELETE RESTRICT,
|
||||||
|
included boolean NOT NULL DEFAULT true,
|
||||||
|
added_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(report_id,media_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_media_media_id ON survey_target_report_media(media_id)`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_media_included ON survey_target_report_media(report_id,included)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_target_report_versions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
report_id uuid NOT NULL REFERENCES survey_target_reports(id) ON DELETE RESTRICT,
|
||||||
|
version_number integer NOT NULL,
|
||||||
|
event varchar(24) NOT NULL,
|
||||||
|
snapshot jsonb NOT NULL,
|
||||||
|
actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
actor_username varchar(80),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT uq_survey_target_report_versions_number UNIQUE(report_id,version_number),
|
||||||
|
CONSTRAINT chk_survey_target_report_versions_event CHECK (
|
||||||
|
event IN ('SUBMITTED','APPROVED','REJECTED')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_versions_created_at ON survey_target_report_versions(created_at)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO permissions(code,description) VALUES
|
||||||
|
('surveys.read','Consultar planificación de relevamientos'),
|
||||||
|
('surveys.manage','Crear y actualizar campañas de relevamiento'),
|
||||||
|
('surveys.assign','Asignar objetivos de relevamiento'),
|
||||||
|
('surveys.execute','Actualizar el avance de objetivos asignados'),
|
||||||
|
('surveys.read_reports','Consultar capturas y versiones de relevamientos'),
|
||||||
|
('surveys.capture','Capturar y enviar relevamientos de campo'),
|
||||||
|
('surveys.review','Aprobar o rechazar relevamientos enviados')
|
||||||
|
ON CONFLICT(code) DO UPDATE SET description=EXCLUDED.description
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues})
|
||||||
|
INSERT INTO role_permissions(role_id,permission_id)
|
||||||
|
SELECT role.id,permission.id
|
||||||
|
FROM mapping
|
||||||
|
JOIN roles role ON role.code=mapping.role_code
|
||||||
|
JOIN permissions permission ON permission.code=mapping.permission_code
|
||||||
|
ON CONFLICT(role_id,permission_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
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 survey_campaigns,survey_campaign_targets,survey_target_reports,survey_target_report_media TO ${applicationRole}`);
|
||||||
|
await queryRunner.query(`GRANT SELECT,INSERT ON TABLE survey_target_report_versions TO ${applicationRole}`);
|
||||||
|
await queryRunner.query(`REVOKE DELETE ON TABLE survey_campaigns,survey_campaign_targets,survey_target_reports,survey_target_report_media,survey_target_report_versions FROM ${applicationRole}`);
|
||||||
|
await queryRunner.query(`REVOKE UPDATE ON TABLE survey_target_report_versions FROM ${applicationRole}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
const legacyDirectorPermissions = [
|
||||||
|
['inspection_reports.revise', 'Cargar versiones corregidas del informe para revisión directiva'],
|
||||||
|
['inspection_reports.review', 'Aprobar la versión vigente del informe'],
|
||||||
|
['inspection_reports.sign_final', 'Firmar electrónicamente el informe final aprobado'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retira definitivamente el circuito activo legacy Director -> revisión -> aprobación -> firma.
|
||||||
|
*
|
||||||
|
* current_revision_number e inspection_report_revisions se conservan porque forman parte del
|
||||||
|
* versionado útil del INF. inspection_report_signatures también se conserva como archivo
|
||||||
|
* histórico: F4 quita sus endpoints/permisos pero no destruye evidencia documental ya emitida.
|
||||||
|
*/
|
||||||
|
export class F4RemoveDirectorReportLegacy1790000400000 implements MigrationInterface {
|
||||||
|
name = 'F4RemoveDirectorReportLegacy1790000400000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Las entregas al Director pertenecían al flujo retirado y no deben seguir
|
||||||
|
// apareciendo como pendientes/reintentables en F4.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM inspection_document_deliveries
|
||||||
|
WHERE recipient_kind = 'DIRECTOR'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Retira permisos huérfanos del circuito anterior para que tampoco aparezcan
|
||||||
|
// como capacidades administrables en RBAC una vez eliminados sus endpoints.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM role_permissions
|
||||||
|
WHERE permission_id IN (
|
||||||
|
SELECT id FROM permissions
|
||||||
|
WHERE code IN (
|
||||||
|
'inspection_reports.revise',
|
||||||
|
'inspection_reports.review',
|
||||||
|
'inspection_reports.sign_final'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM permissions
|
||||||
|
WHERE code IN (
|
||||||
|
'inspection_reports.revise',
|
||||||
|
'inspection_reports.review',
|
||||||
|
'inspection_reports.sign_final'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_revision_id`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_by`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_at`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS review_note`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS signed_at`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS review_status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// El rollback reconstruye el contrato técnico legacy, pero no recrea entregas Director
|
||||||
|
// ni valores de aprobación eliminados. El backup PRE-F4 sigue siendo la fuente de
|
||||||
|
// recuperación de esos datos históricos si alguna vez fuera necesario volver al circuito.
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
ADD COLUMN IF NOT EXISTS review_status varchar(32) NOT NULL DEFAULT 'PENDING_REVIEW'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_revision_id uuid`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_by uuid`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_at timestamptz`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS review_note varchar(1000)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS signed_at timestamptz`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
ADD CONSTRAINT chk_inspection_reports_review_status
|
||||||
|
CHECK (review_status IN ('PENDING_REVIEW','APPROVED','SIGNED'));
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
ADD CONSTRAINT fk_inspection_reports_approved_by
|
||||||
|
FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
ADD CONSTRAINT fk_inspection_reports_approved_revision
|
||||||
|
FOREIGN KEY (approved_revision_id) REFERENCES inspection_report_revisions(id) ON DELETE RESTRICT;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const [code, description] of legacyDirectorPermissions) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO permissions (code, description) VALUES ($1, $2) ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description`,
|
||||||
|
[code, description],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH mapping(role_code, permission_code) AS (VALUES
|
||||||
|
('director', 'inspection_reports.revise'),
|
||||||
|
('director', 'inspection_reports.review'),
|
||||||
|
('director', 'inspection_reports.sign_final')
|
||||||
|
)
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT role.id, permission.id
|
||||||
|
FROM mapping
|
||||||
|
JOIN roles role ON role.code = mapping.role_code
|
||||||
|
JOIN permissions permission ON permission.code = mapping.permission_code
|
||||||
|
ON CONFLICT (role_id, permission_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F4 elimina dos campos heredados de la planificación original de Inspecciones:
|
||||||
|
* - title: la Inspección se identifica por su código autogenerado.
|
||||||
|
* - planned_end_at: la planificación sólo define la fecha/hora prevista de inicio.
|
||||||
|
*
|
||||||
|
* La migración conserva una copia técnica de los valores eliminados para que un
|
||||||
|
* rollback restaure los datos exactos existentes antes del cambio. Inspecciones
|
||||||
|
* creadas después del up() reciben code como title y planned_end_at NULL al bajar.
|
||||||
|
*
|
||||||
|
* Se conserva un índice sobre planned_start_at porque sigue siendo un criterio de
|
||||||
|
* orden y filtro operativo. Ninguna migración histórica se modifica.
|
||||||
|
*/
|
||||||
|
export class F4RemoveInspectionLegacyFields1790000500000 implements MigrationInterface {
|
||||||
|
name = 'F4RemoveInspectionLegacyFields1790000500000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS f4_inspection_visit_legacy_fields_backup (
|
||||||
|
visit_id uuid PRIMARY KEY,
|
||||||
|
title varchar(200) NOT NULL,
|
||||||
|
planned_end_at timestamptz NULL,
|
||||||
|
backed_up_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO f4_inspection_visit_legacy_fields_backup (
|
||||||
|
visit_id,
|
||||||
|
title,
|
||||||
|
planned_end_at,
|
||||||
|
backed_up_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
planned_end_at,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
FROM inspection_visits
|
||||||
|
ON CONFLICT (visit_id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
planned_end_at = EXCLUDED.planned_end_at,
|
||||||
|
backed_up_at = EXCLUDED.backed_up_at
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_visits_planned_dates`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visits
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_visits_planned_dates
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN IF EXISTS planned_end_at`);
|
||||||
|
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN IF EXISTS title`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_start_at
|
||||||
|
ON inspection_visits (planned_start_at)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_visits_planned_start_at`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visits
|
||||||
|
ADD COLUMN IF NOT EXISTS title varchar(200)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visits
|
||||||
|
ADD COLUMN IF NOT EXISTS planned_end_at timestamptz
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inspection_visits visit
|
||||||
|
SET
|
||||||
|
title = backup.title,
|
||||||
|
planned_end_at = backup.planned_end_at
|
||||||
|
FROM f4_inspection_visit_legacy_fields_backup backup
|
||||||
|
WHERE backup.visit_id = visit.id
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inspection_visits
|
||||||
|
SET title = code
|
||||||
|
WHERE title IS NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_visits
|
||||||
|
ALTER COLUMN title SET NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'chk_inspection_visits_planned_dates'
|
||||||
|
AND conrelid = 'inspection_visits'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE inspection_visits
|
||||||
|
ADD CONSTRAINT chk_inspection_visits_planned_dates CHECK (
|
||||||
|
planned_start_at IS NULL
|
||||||
|
OR planned_end_at IS NULL
|
||||||
|
OR planned_end_at >= planned_start_at
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_dates
|
||||||
|
ON inspection_visits (planned_start_at, planned_end_at)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS f4_inspection_visit_legacy_fields_backup`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+274
@@ -0,0 +1,274 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alinea las restricciones físicas heredadas de D5 con el ciclo documental F4.
|
||||||
|
*
|
||||||
|
* F4 incorpora estados LOCKED/SEALED para Actas y WORKING/OFFICIALIZED para INF.
|
||||||
|
* También usa el código visible ACT-NNNNN-DD-MM-YY y entrega copia al INSPECTOR.
|
||||||
|
* Las migraciones históricas conservaban CHECKs, triggers y contratos de D5, por lo
|
||||||
|
* que el servicio podía compilar pero PostgreSQL rechazaba las transiciones nuevas.
|
||||||
|
*/
|
||||||
|
export class F4AlignDocumentLifecycleConstraints1790000600000 implements MigrationInterface {
|
||||||
|
name = 'F4AlignDocumentLifecycleConstraints1790000600000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_reports_status
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
ADD CONSTRAINT chk_inspection_reports_status CHECK (
|
||||||
|
status IN ('WORKING','OFFICIALIZED','FROZEN','CANCELLED')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
ALTER COLUMN status SET DEFAULT 'WORKING'
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_acts_status
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
ADD CONSTRAINT chk_inspection_acts_status CHECK (
|
||||||
|
status IN ('DRAFT','LOCKED','SEALED','READY','CLOSED','CANCELLED','RECTIFIED')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Conservamos los códigos históricos ACTA-* ya emitidos y habilitamos el
|
||||||
|
// formato F4 realmente generado por InspectionActsService.
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_acts_code
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
ADD CONSTRAINT chk_inspection_acts_code CHECK (
|
||||||
|
code ~ '^(ACTA-[0-9]{4}-[0-9]{6}|ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2})$'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_act_versions
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_act_versions
|
||||||
|
ADD CONSTRAINT chk_inspection_act_versions_event CHECK (
|
||||||
|
event IN ('CREATED','UPDATED','LOCKED','SEALED','READY','REOPENED','CLOSED','CANCELLED')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// El cierre físico F4 se representa como SEALED. Conservamos CLOSED como
|
||||||
|
// compatibilidad histórica y exigimos la misma integridad en ambos estados.
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
ADD CONSTRAINT chk_inspection_acts_closure CHECK (
|
||||||
|
status NOT IN ('SEALED','CLOSED')
|
||||||
|
OR (
|
||||||
|
closed_at IS NOT NULL
|
||||||
|
AND closed_by IS NOT NULL
|
||||||
|
AND closure_sha256 ~ '^[0-9a-f]{64}$'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// F4 retira Director como destinatario activo y agrega copia al inspector.
|
||||||
|
// La migración 040 ya eliminó previamente las filas DIRECTOR existentes.
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
DROP CONSTRAINT IF EXISTS 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','INSPECTOR')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Las firmas F4 pertenecen al snapshot LOCKED. El trigger D5 exigía READY.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE current_status varchar(24);
|
||||||
|
DECLARE current_sha char(64);
|
||||||
|
BEGIN
|
||||||
|
SELECT act.status, closure.prepared_sha256
|
||||||
|
INTO current_status, current_sha
|
||||||
|
FROM inspection_acts act
|
||||||
|
INNER JOIN inspection_act_closures closure ON closure.act_id = act.id
|
||||||
|
WHERE act.id = NEW.act_id;
|
||||||
|
IF current_status <> 'LOCKED' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN
|
||||||
|
RAISE EXCEPTION 'La firma no corresponde a un acta bloqueada vigente';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
|
||||||
|
// SEALED es tan inmutable como CLOSED/RECTIFIED en el contrato histórico.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.status IN ('SEALED','CLOSED','RECTIFIED') THEN
|
||||||
|
RAISE EXCEPTION 'El acta sellada es inmutable';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Primero restauramos la guarda D5. De otro modo el propio trigger F4 impediría
|
||||||
|
// traducir una fila SEALED a CLOSED durante el rollback.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.status IN ('CLOSED','RECTIFIED') THEN
|
||||||
|
RAISE EXCEPTION 'El acta cerrada es inmutable';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Los códigos F4 se traducen antes de convertir SEALED a CLOSED, porque una vez
|
||||||
|
// restaurada la guarda D5 una fila CLOSED ya no puede modificarse.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inspection_acts
|
||||||
|
SET code='ACTA-' || act_year::text || '-' || LPAD(act_number::text, 6, '0')
|
||||||
|
WHERE code ~ '^ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_acts_code
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
ADD CONSTRAINT chk_inspection_acts_code CHECK (
|
||||||
|
code ~ '^ACTA-[0-9]{4}-[0-9]{6}$'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// El contrato D5 no conoce los estados F4. Reducimos las representaciones
|
||||||
|
// nuevas a sus equivalentes históricos antes de restaurar sus CHECKs.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inspection_reports
|
||||||
|
SET status='FROZEN'
|
||||||
|
WHERE status IN ('WORKING','OFFICIALIZED')
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
ALTER COLUMN status SET DEFAULT 'FROZEN'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_reports_status
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_reports
|
||||||
|
ADD CONSTRAINT chk_inspection_reports_status CHECK (
|
||||||
|
status IN ('FROZEN','CANCELLED')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inspection_acts
|
||||||
|
SET status=CASE
|
||||||
|
WHEN status='LOCKED' THEN 'READY'
|
||||||
|
WHEN status='SEALED' THEN 'CLOSED'
|
||||||
|
ELSE status
|
||||||
|
END
|
||||||
|
WHERE status IN ('LOCKED','SEALED')
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_acts_status
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
ADD CONSTRAINT chk_inspection_acts_status CHECK (
|
||||||
|
status IN ('DRAFT','READY','CLOSED','CANCELLED','RECTIFIED')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inspection_act_versions
|
||||||
|
SET event=CASE
|
||||||
|
WHEN event='LOCKED' THEN 'READY'
|
||||||
|
WHEN event='SEALED' THEN 'CLOSED'
|
||||||
|
ELSE event
|
||||||
|
END
|
||||||
|
WHERE event IN ('LOCKED','SEALED')
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_act_versions
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_act_versions
|
||||||
|
ADD CONSTRAINT chk_inspection_act_versions_event CHECK (
|
||||||
|
event IN ('CREATED','UPDATED','READY','REOPENED','CLOSED','CANCELLED')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_acts
|
||||||
|
ADD CONSTRAINT chk_inspection_acts_closure CHECK (
|
||||||
|
status <> 'CLOSED'
|
||||||
|
OR (
|
||||||
|
closed_at IS NOT NULL
|
||||||
|
AND closed_by IS NOT NULL
|
||||||
|
AND closure_sha256 ~ '^[0-9a-f]{64}$'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Conservamos INSPECTOR en el CHECK de rollback para no destruir ni falsificar
|
||||||
|
// entregas F4 ya auditadas. D5 puede seguir operando con sus tres valores y el
|
||||||
|
// backup PRE-F4 sigue siendo la fuente de una restauración histórica exacta.
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE inspection_document_deliveries
|
||||||
|
DROP CONSTRAINT IF EXISTS 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 OR REPLACE FUNCTION dhv2_guard_act_signature_ready()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE current_status varchar(24);
|
||||||
|
DECLARE current_sha char(64);
|
||||||
|
BEGIN
|
||||||
|
SELECT act.status, closure.prepared_sha256
|
||||||
|
INTO current_status, current_sha
|
||||||
|
FROM inspection_acts act
|
||||||
|
INNER JOIN inspection_act_closures closure ON closure.act_id = act.id
|
||||||
|
WHERE act.id = NEW.act_id;
|
||||||
|
IF current_status <> 'READY' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN
|
||||||
|
RAISE EXCEPTION 'La firma no corresponde a un acta preparada vigente';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F4AlignAssetVersionFunctionChange1790000700000 implements MigrationInterface {
|
||||||
|
name = 'F4AlignAssetVersionFunctionChange1790000700000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE asset_versions
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE asset_versions
|
||||||
|
ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
|
||||||
|
'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED', 'FUNCTION_CHANGED',
|
||||||
|
'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',
|
||||||
|
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',
|
||||||
|
'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',
|
||||||
|
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
|
||||||
|
))
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE asset_versions
|
||||||
|
SET change_type='UPDATED'
|
||||||
|
WHERE change_type='FUNCTION_CHANGED'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE asset_versions
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE asset_versions
|
||||||
|
ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
|
||||||
|
'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED',
|
||||||
|
'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',
|
||||||
|
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',
|
||||||
|
'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',
|
||||||
|
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
|
||||||
|
))
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class F5InventoryPhysicalInstance1790087100000 implements MigrationInterface {
|
||||||
|
name = 'F5InventoryPhysicalInstance1790087100000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE assets
|
||||||
|
ADD COLUMN is_inventory_instance boolean NOT NULL DEFAULT false
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX idx_assets_inventory_instance_active
|
||||||
|
ON assets (is_inventory_instance, information_status)
|
||||||
|
WHERE is_inventory_instance = true
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
COMMENT ON COLUMN assets.is_inventory_instance IS
|
||||||
|
'True only for a concrete Instalacion/Subinstalacion instance. Empresa, Area and Yacimiento are structural/context masters.'
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION classify_new_asset_inventory_instance()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
type_code varchar;
|
||||||
|
BEGIN
|
||||||
|
SELECT lower(code)
|
||||||
|
INTO type_code
|
||||||
|
FROM asset_types
|
||||||
|
WHERE id = NEW.asset_type_id;
|
||||||
|
|
||||||
|
NEW.is_inventory_instance := COALESCE(type_code, '') IN ('instalacion', 'subinstalacion');
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TRIGGER trg_assets_classify_inventory_instance
|
||||||
|
BEFORE INSERT OR UPDATE OF asset_type_id ON assets
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION classify_new_asset_inventory_instance()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_assets_classify_inventory_instance ON assets');
|
||||||
|
await queryRunner.query('DROP FUNCTION IF EXISTS classify_new_asset_inventory_instance()');
|
||||||
|
await queryRunner.query('DROP INDEX IF EXISTS idx_assets_inventory_instance_active');
|
||||||
|
await queryRunner.query('ALTER TABLE assets DROP COLUMN IF EXISTS is_inventory_instance');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
type TypeRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
role: string;
|
||||||
|
active: boolean;
|
||||||
|
canBeRoot: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuleRow = {
|
||||||
|
childTypeId: string;
|
||||||
|
parentTypeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CountRow = { total: number };
|
||||||
|
|
||||||
|
const CREATED_TYPES_TABLE = 'f5_canonical_hierarchy_created_types';
|
||||||
|
const CREATED_RULES_TABLE = 'f5_canonical_hierarchy_created_rules';
|
||||||
|
|
||||||
|
const CANONICAL_TYPES = [
|
||||||
|
{
|
||||||
|
code: 'yacimiento',
|
||||||
|
name: 'Yacimiento',
|
||||||
|
description: 'Yacimiento perteneciente a un Área.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'instalacion',
|
||||||
|
name: 'Instalación',
|
||||||
|
description: 'Instancia física de una Instalación dentro de un Yacimiento.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'subinstalacion',
|
||||||
|
name: 'Subinstalación',
|
||||||
|
description: 'Instancia física subordinada a una Instalación.',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CANONICAL_RULES = [
|
||||||
|
['yacimiento', 'area'],
|
||||||
|
['instalacion', 'yacimiento'],
|
||||||
|
['subinstalacion', 'instalacion'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class F5CanonicalInventoryHierarchy1790087150000 implements MigrationInterface {
|
||||||
|
name = 'F5CanonicalInventoryHierarchy1790087150000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE ${CREATED_TYPES_TABLE} (
|
||||||
|
type_id uuid PRIMARY KEY,
|
||||||
|
code varchar(80) NOT NULL UNIQUE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_f5_canonical_created_type FOREIGN KEY (type_id)
|
||||||
|
REFERENCES asset_types(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE ${CREATED_RULES_TABLE} (
|
||||||
|
child_type_id uuid NOT NULL,
|
||||||
|
parent_type_id uuid NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (child_type_id,parent_type_id),
|
||||||
|
CONSTRAINT fk_f5_canonical_created_rule_child FOREIGN KEY (child_type_id)
|
||||||
|
REFERENCES asset_types(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_f5_canonical_created_rule_parent FOREIGN KEY (parent_type_id)
|
||||||
|
REFERENCES asset_types(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const area = await this.requireType(queryRunner, 'area');
|
||||||
|
if (area.role !== 'AREA' || !area.active || !area.canBeRoot) {
|
||||||
|
throw new Error('F5 requires canonical active root type area with AREA operational role');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const definition of CANONICAL_TYPES) {
|
||||||
|
const inserted = (await queryRunner.query(`
|
||||||
|
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
|
||||||
|
SELECT $1::varchar,$2::varchar,$3::text,false,true,'GENERIC'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar)
|
||||||
|
)
|
||||||
|
RETURNING id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
|
||||||
|
`, [definition.code, definition.name, definition.description])) as TypeRow[];
|
||||||
|
|
||||||
|
if (inserted[0]?.id) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO ${CREATED_TYPES_TABLE}(type_id,code)
|
||||||
|
VALUES ($1::uuid,$2::varchar)
|
||||||
|
`, [inserted[0].id, definition.code]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = await this.requireType(queryRunner, definition.code);
|
||||||
|
if (type.role !== 'GENERIC' || !type.active || type.canBeRoot) {
|
||||||
|
throw new Error(`F5 incompatible canonical type configuration: ${definition.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [childCode, parentCode] of CANONICAL_RULES) {
|
||||||
|
const inserted = (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::varchar)
|
||||||
|
AND lower(parent.code)=lower($2::varchar)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM asset_type_parent_rules existing
|
||||||
|
WHERE existing.child_type_id=child.id AND existing.parent_type_id=parent.id
|
||||||
|
)
|
||||||
|
RETURNING child_type_id AS "childTypeId",parent_type_id AS "parentTypeId"
|
||||||
|
`, [childCode, parentCode])) as RuleRow[];
|
||||||
|
|
||||||
|
if (inserted[0]?.childTypeId && inserted[0]?.parentTypeId) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO ${CREATED_RULES_TABLE}(child_type_id,parent_type_id)
|
||||||
|
VALUES ($1::uuid,$2::uuid)
|
||||||
|
`, [inserted[0].childTypeId, inserted[0].parentTypeId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [verified] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM asset_type_parent_rules rule
|
||||||
|
JOIN asset_types child ON child.id=rule.child_type_id
|
||||||
|
JOIN asset_types parent ON parent.id=rule.parent_type_id
|
||||||
|
WHERE (lower(child.code)='yacimiento' AND lower(parent.code)='area')
|
||||||
|
OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento')
|
||||||
|
OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion')
|
||||||
|
`)) as CountRow[];
|
||||||
|
if (Number(verified?.total ?? 0) !== 3) {
|
||||||
|
throw new Error(`F5 canonical hierarchy verification failed: rules=${Number(verified?.total ?? 0)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const [usedCreatedTypes] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM assets asset
|
||||||
|
JOIN ${CREATED_TYPES_TABLE} owned ON owned.type_id=asset.asset_type_id
|
||||||
|
`)) as CountRow[];
|
||||||
|
if (Number(usedCreatedTypes?.total ?? 0) !== 0) {
|
||||||
|
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type is already used by Inventory');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [foreignRules] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM asset_type_parent_rules rule
|
||||||
|
WHERE (
|
||||||
|
rule.child_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
|
||||||
|
OR rule.parent_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM ${CREATED_RULES_TABLE} owned
|
||||||
|
WHERE owned.child_type_id=rule.child_type_id
|
||||||
|
AND owned.parent_type_id=rule.parent_type_id
|
||||||
|
)
|
||||||
|
`)) as CountRow[];
|
||||||
|
if (Number(foreignRules?.total ?? 0) !== 0) {
|
||||||
|
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type gained external parent rules');
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM asset_type_parent_rules rule
|
||||||
|
USING ${CREATED_RULES_TABLE} owned
|
||||||
|
WHERE rule.child_type_id=owned.child_type_id
|
||||||
|
AND rule.parent_type_id=owned.parent_type_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM asset_types type
|
||||||
|
USING ${CREATED_TYPES_TABLE} owned
|
||||||
|
WHERE type.id=owned.type_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP TABLE ${CREATED_RULES_TABLE}`);
|
||||||
|
await queryRunner.query(`DROP TABLE ${CREATED_TYPES_TABLE}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireType(queryRunner: QueryRunner, code: string): Promise<TypeRow> {
|
||||||
|
const rows = (await queryRunner.query(`
|
||||||
|
SELECT id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
|
||||||
|
FROM asset_types
|
||||||
|
WHERE lower(code)=lower($1::varchar)
|
||||||
|
ORDER BY created_at
|
||||||
|
`, [code])) as TypeRow[];
|
||||||
|
|
||||||
|
if (rows.length !== 1) {
|
||||||
|
throw new Error(`F5 requires exactly one canonical asset type ${code}; found ${rows.length}`);
|
||||||
|
}
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import { loadF5InventoryAuthoritativeSource } from '../../reference-data/f5-authoritative-inventory-source';
|
||||||
|
|
||||||
|
type IdRow = { id: string };
|
||||||
|
type CountRow = { total: number };
|
||||||
|
|
||||||
|
const TERRITORY_DOCUMENT_NUMBER = 'DH-F5-TERRITORY';
|
||||||
|
const TERRITORY_SOURCE_NAME = 'Tablas de yacimiento y areas.xlsx';
|
||||||
|
const BACKUP_TABLE = 'f5_territory_relation_backups';
|
||||||
|
|
||||||
|
function key(value: string): string {
|
||||||
|
return value.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function code(prefix: string, value: string, length = 12): string {
|
||||||
|
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueBy<T>(values: T[], identity: (value: T) => string): T[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const output: T[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const id = identity(value);
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
output.push(value);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface {
|
||||||
|
name = 'F5AuthoritativeTerritory1790087200000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const source = loadF5InventoryAuthoritativeSource();
|
||||||
|
if (
|
||||||
|
source.areaSource.file !== TERRITORY_SOURCE_NAME
|
||||||
|
|| source.areaSource.sheet !== 'cr26e_tabla1'
|
||||||
|
|| source.areaSource.sha256 !== '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78'
|
||||||
|
|| source.areaSource.rows.length !== 230
|
||||||
|
) {
|
||||||
|
throw new Error('F5 territory source contract mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = source.areaSource.rows;
|
||||||
|
const areaRows = uniqueBy(rows, (row) => key(row.area));
|
||||||
|
const pairRows = uniqueBy(rows, (row) => `${key(row.area)}|${key(row.yacimiento)}`);
|
||||||
|
if (areaRows.length !== 64 || pairRows.length !== 230) {
|
||||||
|
throw new Error(`F5 territory cardinality mismatch: areas=${areaRows.length}, pairs=${pairRows.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every Area must have one unambiguous source context. The workbook is the
|
||||||
|
// only authority for this preload; conflicting rows must abort the migration.
|
||||||
|
for (const areaRow of areaRows) {
|
||||||
|
const sameArea = rows.filter((row) => key(row.area)===key(areaRow.area));
|
||||||
|
const dimensions = [
|
||||||
|
new Set(sameArea.map((row) => key(row.departamento))),
|
||||||
|
new Set(sameArea.map((row) => key(row.tipoConcesion))),
|
||||||
|
new Set(sameArea.map((row) => key(row.empresaOperadora))),
|
||||||
|
];
|
||||||
|
if (dimensions.some((values) => values.size !== 1)) {
|
||||||
|
throw new Error(`F5 territory source has conflicting Area context: ${areaRow.area}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assertCanonicalTypesAndRules(queryRunner);
|
||||||
|
await this.installHierarchyGuard(queryRunner);
|
||||||
|
await this.ensureBackupTable(queryRunner);
|
||||||
|
|
||||||
|
const preExistingDocument = await this.optionalId(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
|
||||||
|
[TERRITORY_DOCUMENT_NUMBER],
|
||||||
|
);
|
||||||
|
if (preExistingDocument) {
|
||||||
|
throw new Error('F5 territory source document already exists before migration');
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedDocument = (await queryRunner.query(`
|
||||||
|
INSERT INTO source_documents (
|
||||||
|
document_type, document_number, title, issuer, external_reference, notes
|
||||||
|
) VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4)
|
||||||
|
RETURNING id
|
||||||
|
`, [
|
||||||
|
TERRITORY_DOCUMENT_NUMBER,
|
||||||
|
TERRITORY_SOURCE_NAME,
|
||||||
|
`sha256:${source.areaSource.sha256}`,
|
||||||
|
`F5 · fuente territorial autorizada · hoja ${source.areaSource.sheet} · ${rows.length} filas`,
|
||||||
|
])) as IdRow[];
|
||||||
|
const sourceDocumentId = insertedDocument[0]?.id;
|
||||||
|
if (!sourceDocumentId) throw new Error('F5 could not create territory source document');
|
||||||
|
|
||||||
|
const companyTypeId = await this.id(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`,
|
||||||
|
[],
|
||||||
|
'COMPANY asset type',
|
||||||
|
);
|
||||||
|
const areaTypeId = await this.id(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM asset_types WHERE operational_role='AREA' AND is_active=true ORDER BY (lower(code)='area') DESC,created_at LIMIT 1`,
|
||||||
|
[],
|
||||||
|
'AREA asset type',
|
||||||
|
);
|
||||||
|
const fieldTypeId = await this.id(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`,
|
||||||
|
[],
|
||||||
|
'Yacimiento asset type',
|
||||||
|
);
|
||||||
|
|
||||||
|
const companyNames = [...new Set(rows.map((row) => row.empresaOperadora.trim()))]
|
||||||
|
.filter((name) => name && key(name) !== key('Sin Empresa Operadora'))
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'es'));
|
||||||
|
const companyIds = new Map<string, string>();
|
||||||
|
for (const companyName of companyNames) {
|
||||||
|
const companyId = await this.ensureRootAsset(queryRunner, {
|
||||||
|
typeId: companyTypeId,
|
||||||
|
role: 'COMPANY',
|
||||||
|
code: code('F5-ORG', key(companyName)),
|
||||||
|
name: companyName,
|
||||||
|
sourceDocumentId,
|
||||||
|
sourceReference: `F5:TERRITORY:COMPANY:${code('SRC', key(companyName), 10)}`,
|
||||||
|
});
|
||||||
|
companyIds.set(key(companyName), companyId);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO organization_profiles (asset_id,organization_kind,legal_name)
|
||||||
|
VALUES ($1::uuid,$2::organization_kind,$3)
|
||||||
|
ON CONFLICT (asset_id) DO NOTHING
|
||||||
|
`, [companyId, companyName.trim().toUpperCase().startsWith('UTE (') ? 'UTE' : 'COMPANY', companyName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const departmentIds = new Map<string, string>();
|
||||||
|
for (const departmentName of [...new Set(rows.map((row) => row.departamento.trim()))].sort((a,b)=>a.localeCompare(b,'es'))) {
|
||||||
|
const normalized = key(departmentName);
|
||||||
|
let departmentId = await this.optionalId(queryRunner, `
|
||||||
|
SELECT id FROM administrative_departments
|
||||||
|
WHERE province_code='MENDOZA' AND normalized_name=$1 AND is_active=true
|
||||||
|
LIMIT 1
|
||||||
|
`, [normalized]);
|
||||||
|
if (!departmentId) {
|
||||||
|
const inserted = (await queryRunner.query(`
|
||||||
|
INSERT INTO administrative_departments (
|
||||||
|
province_code,code,name,normalized_name,is_active,source_document_id
|
||||||
|
) VALUES ('MENDOZA',$1,$2,$3,true,$4::uuid)
|
||||||
|
RETURNING id
|
||||||
|
`, [code('F5-DEP', normalized, 10), departmentName, normalized, sourceDocumentId])) as IdRow[];
|
||||||
|
departmentId=inserted[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!departmentId) throw new Error(`F5 could not seed department ${departmentName}`);
|
||||||
|
departmentIds.set(normalized, departmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaIds = new Map<string, string>();
|
||||||
|
for (const areaRow of areaRows) {
|
||||||
|
const areaId = await this.ensureRootAsset(queryRunner, {
|
||||||
|
typeId: areaTypeId,
|
||||||
|
role: 'AREA',
|
||||||
|
code: code('F5-AREA', key(areaRow.area)),
|
||||||
|
name: areaRow.area,
|
||||||
|
sourceDocumentId,
|
||||||
|
sourceReference: `F5:TERRITORY:AREA:${code('SRC', key(areaRow.area), 10)}`,
|
||||||
|
});
|
||||||
|
areaIds.set(key(areaRow.area), areaId);
|
||||||
|
|
||||||
|
const departmentId = departmentIds.get(key(areaRow.departamento));
|
||||||
|
if (!departmentId) throw new Error(`F5 missing department ${areaRow.departamento}`);
|
||||||
|
await this.backupAndCloseDepartmentRelations(queryRunner,areaId,departmentId);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO area_department_relations (
|
||||||
|
area_id,department_id,valid_from,source_document_id,notes
|
||||||
|
)
|
||||||
|
SELECT $1::uuid,$2::uuid,CURRENT_DATE,$3::uuid,$4
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM area_department_relations
|
||||||
|
WHERE area_id=$1::uuid AND department_id=$2::uuid AND valid_until IS NULL
|
||||||
|
)
|
||||||
|
`, [
|
||||||
|
areaId,
|
||||||
|
departmentId,
|
||||||
|
sourceDocumentId,
|
||||||
|
`F5 · ${TERRITORY_SOURCE_NAME} · ${source.areaSource.sheet}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const operatorId = companyIds.get(key(areaRow.empresaOperadora)) ?? null;
|
||||||
|
await this.backupAndCloseOperatorRelations(queryRunner,areaId,operatorId);
|
||||||
|
if (operatorId) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO area_company_relations (
|
||||||
|
area_id,company_id,relation_role,source_document_id,valid_from,start_reason
|
||||||
|
)
|
||||||
|
SELECT $1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4
|
||||||
|
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,
|
||||||
|
operatorId,
|
||||||
|
sourceDocumentId,
|
||||||
|
`F5 · operadora vigente según ${TERRITORY_SOURCE_NAME}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rightType = areaRow.tipoConcesion === 'Exploración'
|
||||||
|
? 'EXPLORATION_PERMIT'
|
||||||
|
: areaRow.tipoConcesion === 'Explotación'
|
||||||
|
? 'EXPLOITATION_CONCESSION'
|
||||||
|
: 'OTHER';
|
||||||
|
const rightName = `${areaRow.tipoConcesion} · ${areaRow.area}`;
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO area_legal_rights (
|
||||||
|
area_id,right_type,name,status,source_document_id,notes
|
||||||
|
)
|
||||||
|
SELECT $1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM area_legal_rights
|
||||||
|
WHERE area_id=$1::uuid
|
||||||
|
AND right_type=$2::area_legal_right_type
|
||||||
|
AND lower(btrim(name))=lower(btrim($3::varchar))
|
||||||
|
AND status IN ('ACTIVE','PENDING')
|
||||||
|
)
|
||||||
|
`, [
|
||||||
|
areaId,
|
||||||
|
rightType,
|
||||||
|
rightName,
|
||||||
|
sourceDocumentId,
|
||||||
|
`F5 · tipo de concesión tomado literalmente de ${TERRITORY_SOURCE_NAME}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of pairRows) {
|
||||||
|
const areaId = areaIds.get(key(row.area));
|
||||||
|
if (!areaId) throw new Error(`F5 missing area ${row.area}`);
|
||||||
|
const sourceReference = `F5:TERRITORY:YAC:${code('SRC', `${key(row.area)}|${key(row.yacimiento)}`, 12)}`;
|
||||||
|
let yacimientoId = await this.optionalId(queryRunner, `
|
||||||
|
SELECT asset.id
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE lower(type.code)='yacimiento'
|
||||||
|
AND asset.parent_id=$1::uuid
|
||||||
|
AND asset.information_status<>'INACTIVE'
|
||||||
|
AND lower(btrim(asset.name))=lower(btrim($2))
|
||||||
|
ORDER BY asset.created_at
|
||||||
|
LIMIT 1
|
||||||
|
`, [areaId, row.yacimiento]);
|
||||||
|
if (!yacimientoId) {
|
||||||
|
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,is_inventory_instance
|
||||||
|
) VALUES (
|
||||||
|
$1::uuid,$2::uuid,NULL,NULL,$3,$4,$5,'VALIDATED','UNKNOWN',
|
||||||
|
'PROVIDED_DOCUMENT',$6,$7,$8,false
|
||||||
|
) RETURNING id
|
||||||
|
`, [
|
||||||
|
fieldTypeId,
|
||||||
|
areaId,
|
||||||
|
code('F5-YAC', `${key(row.area)}|${key(row.yacimiento)}`),
|
||||||
|
row.yacimiento,
|
||||||
|
`Yacimiento del Área ${row.area}`,
|
||||||
|
TERRITORY_SOURCE_NAME,
|
||||||
|
sourceReference,
|
||||||
|
`${source.areaSource.sheet} · fila ${row.sourceRow}`,
|
||||||
|
])) as IdRow[];
|
||||||
|
yacimientoId = inserted[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!yacimientoId) throw new Error(`F5 could not seed yacimiento ${row.area} / ${row.yacimiento}`);
|
||||||
|
await this.linkSource(queryRunner, yacimientoId, sourceDocumentId, `Hoja ${source.areaSource.sheet} · fila ${row.sourceRow}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [verification] = (await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA')::integer AS areas,
|
||||||
|
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')::integer AS yacimientos
|
||||||
|
FROM asset_source_documents link
|
||||||
|
JOIN assets asset ON asset.id=link.asset_id
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE link.document_id=$1::uuid
|
||||||
|
`, [sourceDocumentId])) as Array<{ areas: number; yacimientos: number }>;
|
||||||
|
if (Number(verification?.areas ?? 0) !== 64 || Number(verification?.yacimientos ?? 0) !== 230) {
|
||||||
|
throw new Error(`F5 territory preload verification failed: ${JSON.stringify(verification ?? {})}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const sourceDocumentId = await this.optionalId(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
|
||||||
|
[TERRITORY_DOCUMENT_NUMBER],
|
||||||
|
);
|
||||||
|
if (!sourceDocumentId) {
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
|
||||||
|
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.assertRelationBackupsUnchanged(queryRunner);
|
||||||
|
await this.assertCreatedMastersUnused(queryRunner,sourceDocumentId);
|
||||||
|
|
||||||
|
await queryRunner.query(`DELETE FROM area_legal_rights WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
|
||||||
|
|
||||||
|
// Remove relations created by F5 first so restoring the previously-active
|
||||||
|
// relation cannot violate active-relation uniqueness constraints.
|
||||||
|
await queryRunner.query(`DELETE FROM area_company_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
|
||||||
|
await queryRunner.query(`DELETE FROM area_department_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
|
||||||
|
await this.restoreRelationBackups(queryRunner);
|
||||||
|
|
||||||
|
await queryRunner.query(`DELETE FROM asset_source_documents WHERE document_id=$1::uuid`,[sourceDocumentId]);
|
||||||
|
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:YAC:%'`);
|
||||||
|
await queryRunner.query(`DELETE FROM organization_profiles profile USING assets asset WHERE profile.asset_id=asset.id AND asset.source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
|
||||||
|
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
|
||||||
|
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:AREA:%'`);
|
||||||
|
await queryRunner.query(`DELETE FROM administrative_departments WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
|
||||||
|
await queryRunner.query(`DELETE FROM source_documents WHERE id=$1::uuid`,[sourceDocumentId]);
|
||||||
|
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
|
||||||
|
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanonicalTypesAndRules(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const [roles] = (await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE operational_role='AREA' AND is_active=true)::integer AS areas,
|
||||||
|
COUNT(*) FILTER (WHERE operational_role='COMPANY' AND is_active=true)::integer AS companies
|
||||||
|
FROM asset_types
|
||||||
|
`)) as Array<{areas:number; companies:number}>;
|
||||||
|
if (Number(roles?.areas ?? 0)<1 || Number(roles?.companies ?? 0)<1) {
|
||||||
|
throw new Error('F5 requires active AREA and COMPANY master types');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [typeCode,typeName,description] of [
|
||||||
|
['yacimiento','Yacimiento','Yacimiento perteneciente a un Área.'],
|
||||||
|
['instalacion','Instalación','Instancia física de una Instalación dentro de un Yacimiento.'],
|
||||||
|
['subinstalacion','Subinstalación','Instancia física subordinada a una Instalación.'],
|
||||||
|
] as const) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
|
||||||
|
SELECT $1::varchar,$2,$3,false,true,'GENERIC'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar))
|
||||||
|
`,[typeCode,typeName,description]);
|
||||||
|
const [type] = (await queryRunner.query(`
|
||||||
|
SELECT operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
|
||||||
|
FROM asset_types WHERE lower(code)=lower($1) LIMIT 1
|
||||||
|
`,[typeCode])) as Array<{role:string;active:boolean;canBeRoot:boolean}>;
|
||||||
|
if (!type || type.role!=='GENERIC' || !type.active || type.canBeRoot) {
|
||||||
|
throw new Error(`F5 incompatible master type configuration: ${typeCode}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ruleCount] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM asset_type_parent_rules rule
|
||||||
|
JOIN asset_types child ON child.id=rule.child_type_id
|
||||||
|
JOIN asset_types parent ON parent.id=rule.parent_type_id
|
||||||
|
WHERE (lower(child.code)='yacimiento' AND lower(parent.code)='area')
|
||||||
|
OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento')
|
||||||
|
OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion')
|
||||||
|
`)) as CountRow[];
|
||||||
|
if (Number(ruleCount?.total ?? 0)!==3) {
|
||||||
|
throw new Error('F5 requires canonical parent rules Area → Yacimiento → Instalación → Subinstalación');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installHierarchyGuard(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE child_code text; parent_code text;
|
||||||
|
BEGIN
|
||||||
|
SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id;
|
||||||
|
IF child_code IN ('empresa','organizacion','area') THEN
|
||||||
|
IF NEW.parent_id IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Área son maestros raíz independientes';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF child_code NOT IN ('yacimiento','instalacion','subinstalacion') THEN RETURN NEW; END IF;
|
||||||
|
IF NEW.parent_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento, Instalación y Subinstalación requieren padre';
|
||||||
|
END IF;
|
||||||
|
SELECT lower(type.code) INTO parent_code
|
||||||
|
FROM assets parent JOIN asset_types type ON type.id=parent.asset_type_id
|
||||||
|
WHERE parent.id=NEW.parent_id;
|
||||||
|
IF (child_code='yacimiento' AND parent_code<>'area')
|
||||||
|
OR (child_code='instalacion' AND parent_code<>'yacimiento')
|
||||||
|
OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía F5 inválida: Área → Yacimiento → Instalación → Subinstalación';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TRIGGER trg_f5_canonical_asset_hierarchy
|
||||||
|
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBackupTable(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE ${BACKUP_TABLE} (
|
||||||
|
relation_kind varchar(32) NOT NULL,
|
||||||
|
relation_id uuid NOT NULL,
|
||||||
|
previous_values jsonb NOT NULL,
|
||||||
|
applied_values jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (relation_kind,relation_id),
|
||||||
|
CONSTRAINT chk_f5_territory_backup_kind CHECK (relation_kind IN ('AREA_COMPANY','AREA_DEPARTMENT'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async backupAndCloseDepartmentRelations(queryRunner: QueryRunner,areaId:string,departmentId:string):Promise<void> {
|
||||||
|
const marker='F5: reemplazada por fuente territorial autorizada';
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
|
||||||
|
SELECT 'AREA_DEPARTMENT',relation.id,
|
||||||
|
jsonb_build_object('validUntil',relation.valid_until,'notes',relation.notes),
|
||||||
|
jsonb_build_object('validUntil',CURRENT_DATE,'notes',concat_ws(E'\n',relation.notes,$3::text))
|
||||||
|
FROM area_department_relations relation
|
||||||
|
WHERE relation.area_id=$1::uuid
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
AND relation.department_id<>$2::uuid
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`,[areaId,departmentId,marker]);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE area_department_relations relation
|
||||||
|
SET valid_until=(backup.applied_values->>'validUntil')::date,
|
||||||
|
notes=backup.applied_values->>'notes',
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
FROM ${BACKUP_TABLE} backup
|
||||||
|
WHERE backup.relation_kind='AREA_DEPARTMENT'
|
||||||
|
AND backup.relation_id=relation.id
|
||||||
|
AND relation.area_id=$1::uuid
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
`,[areaId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async backupAndCloseOperatorRelations(queryRunner: QueryRunner,areaId:string,operatorId:string|null):Promise<void> {
|
||||||
|
const marker='F5: reemplazada por fuente territorial autorizada';
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
|
||||||
|
SELECT 'AREA_COMPANY',relation.id,
|
||||||
|
jsonb_build_object('validUntil',relation.valid_until,'endReason',relation.end_reason),
|
||||||
|
jsonb_build_object('validUntil',CURRENT_TIMESTAMP,'endReason',$3::text)
|
||||||
|
FROM area_company_relations relation
|
||||||
|
WHERE relation.area_id=$1::uuid
|
||||||
|
AND relation.relation_role='OPERATOR'
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
AND ($2::uuid IS NULL OR relation.company_id<>$2::uuid)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`,[areaId,operatorId,marker]);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE area_company_relations relation
|
||||||
|
SET valid_until=(backup.applied_values->>'validUntil')::timestamptz,
|
||||||
|
end_reason=backup.applied_values->>'endReason',
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
FROM ${BACKUP_TABLE} backup
|
||||||
|
WHERE backup.relation_kind='AREA_COMPANY'
|
||||||
|
AND backup.relation_id=relation.id
|
||||||
|
AND relation.area_id=$1::uuid
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
`,[areaId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertRelationBackupsUnchanged(queryRunner: QueryRunner):Promise<void> {
|
||||||
|
const [changed] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM ${BACKUP_TABLE} backup
|
||||||
|
LEFT JOIN area_company_relations company_relation
|
||||||
|
ON backup.relation_kind='AREA_COMPANY' AND company_relation.id=backup.relation_id
|
||||||
|
LEFT JOIN area_department_relations department_relation
|
||||||
|
ON backup.relation_kind='AREA_DEPARTMENT' AND department_relation.id=backup.relation_id
|
||||||
|
WHERE (
|
||||||
|
backup.relation_kind='AREA_COMPANY'
|
||||||
|
AND (
|
||||||
|
company_relation.id IS NULL
|
||||||
|
OR company_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::timestamptz
|
||||||
|
OR company_relation.end_reason IS DISTINCT FROM backup.applied_values->>'endReason'
|
||||||
|
)
|
||||||
|
) OR (
|
||||||
|
backup.relation_kind='AREA_DEPARTMENT'
|
||||||
|
AND (
|
||||||
|
department_relation.id IS NULL
|
||||||
|
OR department_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::date
|
||||||
|
OR department_relation.notes IS DISTINCT FROM backup.applied_values->>'notes'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`)) as CountRow[];
|
||||||
|
if (Number(changed?.total ?? 0)>0) {
|
||||||
|
throw new Error('Cannot safely rollback F5 territory: a relation closed by the preload was modified afterwards');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreRelationBackups(queryRunner: QueryRunner):Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE area_company_relations relation
|
||||||
|
SET valid_until=(backup.previous_values->>'validUntil')::timestamptz,
|
||||||
|
end_reason=backup.previous_values->>'endReason',
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
FROM ${BACKUP_TABLE} backup
|
||||||
|
WHERE backup.relation_kind='AREA_COMPANY' AND backup.relation_id=relation.id
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE area_department_relations relation
|
||||||
|
SET valid_until=(backup.previous_values->>'validUntil')::date,
|
||||||
|
notes=backup.previous_values->>'notes',
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
FROM ${BACKUP_TABLE} backup
|
||||||
|
WHERE backup.relation_kind='AREA_DEPARTMENT' AND backup.relation_id=relation.id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCreatedMastersUnused(queryRunner:QueryRunner,sourceDocumentId:string):Promise<void> {
|
||||||
|
const [used] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM assets asset
|
||||||
|
WHERE asset.source_reference LIKE 'F5:TERRITORY:%'
|
||||||
|
AND (
|
||||||
|
EXISTS (SELECT 1 FROM assets child WHERE child.parent_id=asset.id AND child.source_reference NOT LIKE 'F5:TERRITORY:%')
|
||||||
|
OR EXISTS (SELECT 1 FROM inspection_visits visit WHERE visit.operational_area_id=asset.id OR visit.operator_company_id=asset.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM inspection_visit_assets link WHERE link.asset_id=asset.id)
|
||||||
|
OR EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.asset_id=asset.id)
|
||||||
|
)
|
||||||
|
`)) as CountRow[];
|
||||||
|
if (Number(used?.total ?? 0)>0) {
|
||||||
|
throw new Error('Cannot safely rollback F5 territory: F5-created master data is already used by operational records');
|
||||||
|
}
|
||||||
|
void sourceDocumentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureRootAsset(
|
||||||
|
queryRunner: QueryRunner,
|
||||||
|
input: {
|
||||||
|
typeId: string;
|
||||||
|
role: 'AREA' | 'COMPANY';
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sourceDocumentId: string;
|
||||||
|
sourceReference: string;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
let assetId = await this.optionalId(queryRunner, `
|
||||||
|
SELECT asset.id
|
||||||
|
FROM assets asset
|
||||||
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||||
|
WHERE type.operational_role=$1::asset_type_operational_role
|
||||||
|
AND asset.information_status<>'INACTIVE'
|
||||||
|
AND lower(btrim(asset.name))=lower(btrim($2))
|
||||||
|
ORDER BY asset.created_at
|
||||||
|
LIMIT 1
|
||||||
|
`,[input.role,input.name]);
|
||||||
|
if (!assetId) {
|
||||||
|
const inserted = (await queryRunner.query(`
|
||||||
|
INSERT INTO assets (
|
||||||
|
asset_type_id,parent_id,operational_area_id,operator_company_id,
|
||||||
|
code,name,information_status,operational_status,data_origin,
|
||||||
|
source_name,source_reference,source_notes,is_inventory_instance
|
||||||
|
) VALUES ($1::uuid,NULL,NULL,NULL,$2,$3,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$4,$5,$6,false)
|
||||||
|
RETURNING id
|
||||||
|
`,[
|
||||||
|
input.typeId,input.code,input.name,TERRITORY_SOURCE_NAME,input.sourceReference,
|
||||||
|
'F5 · fuente territorial autorizada',
|
||||||
|
])) as IdRow[];
|
||||||
|
assetId=inserted[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
if (!assetId) throw new Error(`F5 could not seed ${input.role} ${input.name}`);
|
||||||
|
await this.linkSource(queryRunner,assetId,input.sourceDocumentId,'F5 · fuente territorial autorizada');
|
||||||
|
return assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async linkSource(queryRunner:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
|
||||||
|
VALUES ($1::uuid,$2::uuid,'SOURCE',$3)
|
||||||
|
ON CONFLICT (asset_id,document_id,relation_type) DO UPDATE SET notes=EXCLUDED.notes,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`,[assetId,documentId,notes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise<string> {
|
||||||
|
const value=await this.optionalId(queryRunner,sql,params);
|
||||||
|
if (!value) throw new Error(`F5 could not resolve ${label}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
|
||||||
|
const result=(await queryRunner.query(sql,params)) as IdRow[];
|
||||||
|
return result[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F5 makes the physical hierarchy Area-owned. Empresa is never a parent nor a
|
||||||
|
* required property of Yacimiento/Instalación/Subinstalación. The current and
|
||||||
|
* historical operator/concession truth lives in area_company_relations and is
|
||||||
|
* frozen separately by each Inspección/Acta.
|
||||||
|
*
|
||||||
|
* operator_company_id is retained only as a backwards-compatible creation/
|
||||||
|
* historical snapshot. Runtime ownership and search MUST NOT depend on it.
|
||||||
|
*/
|
||||||
|
export class F5OperationalContextCompatibility1790087250000 implements MigrationInterface {
|
||||||
|
name = 'F5OperationalContextCompatibility1790087250000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await this.installAreaOwnedGuard(queryRunner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await this.installLegacyPairedGuard(queryRunner);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installAreaOwnedGuard(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION enforce_asset_operational_context()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
asset_role asset_type_operational_role;
|
||||||
|
area_role asset_type_operational_role;
|
||||||
|
company_role asset_type_operational_role;
|
||||||
|
active_relation_id uuid;
|
||||||
|
BEGIN
|
||||||
|
SELECT operational_role INTO asset_role
|
||||||
|
FROM asset_types WHERE id=NEW.asset_type_id;
|
||||||
|
|
||||||
|
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
|
||||||
|
IF NEW.operational_area_id IS NOT NULL OR NEW.operator_company_id IS NOT NULL THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',
|
||||||
|
MESSAGE='Área y Empresa no reciben contexto operativo de Inventario';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.operator_company_id IS NOT NULL AND NEW.operational_area_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',
|
||||||
|
MESSAGE='Un snapshot de Empresa requiere un Área física';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Once written, an old/current company snapshot cannot be repointed to
|
||||||
|
-- simulate physical ownership. Company changes happen in the temporal
|
||||||
|
-- Area↔Empresa relation instead.
|
||||||
|
IF TG_OP='UPDATE'
|
||||||
|
AND NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',
|
||||||
|
MESSAGE='La Empresa se cambia en la relación temporal del Área, no en el Inventario';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.operational_area_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT type.operational_role INTO area_role
|
||||||
|
FROM assets area
|
||||||
|
JOIN asset_types type ON type.id=area.asset_type_id
|
||||||
|
WHERE area.id=NEW.operational_area_id
|
||||||
|
AND area.information_status<>'INACTIVE'
|
||||||
|
AND type.is_active=true;
|
||||||
|
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',
|
||||||
|
MESSAGE='operational area must be an active AREA asset';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.parent_id IS NULL OR NOT EXISTS (
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id,parent_id FROM assets WHERE id=NEW.parent_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.id,parent.parent_id
|
||||||
|
FROM assets parent
|
||||||
|
JOIN ancestors child ON parent.id=child.parent_id
|
||||||
|
)
|
||||||
|
SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',
|
||||||
|
MESSAGE='operational area must be an ancestor in the physical hierarchy';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- A company value is allowed only as the context snapshot that was valid
|
||||||
|
-- at creation time. It is never used to decide future membership.
|
||||||
|
IF NEW.operator_company_id IS NOT NULL THEN
|
||||||
|
SELECT type.operational_role INTO company_role
|
||||||
|
FROM assets company
|
||||||
|
JOIN asset_types type ON type.id=company.asset_type_id
|
||||||
|
WHERE company.id=NEW.operator_company_id
|
||||||
|
AND company.information_status<>'INACTIVE'
|
||||||
|
AND type.is_active=true;
|
||||||
|
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',
|
||||||
|
MESSAGE='operator snapshot must reference an active COMPANY-role asset';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF TG_OP='INSERT' THEN
|
||||||
|
SELECT relation.id INTO active_relation_id
|
||||||
|
FROM area_company_relations relation
|
||||||
|
WHERE relation.area_id=NEW.operational_area_id
|
||||||
|
AND relation.company_id=NEW.operator_company_id
|
||||||
|
AND relation.relation_role='OPERATOR'::area_organization_role
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
FOR KEY SHARE;
|
||||||
|
IF active_relation_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',
|
||||||
|
MESSAGE='creation operator snapshot must be active for the selected Area';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restores the production F4-era paired Area+Empresa guard on rollback. */
|
||||||
|
private async installLegacyPairedGuard(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE FUNCTION enforce_asset_operational_context()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
DECLARE
|
||||||
|
asset_role asset_type_operational_role;
|
||||||
|
area_role asset_type_operational_role;
|
||||||
|
company_role asset_type_operational_role;
|
||||||
|
active_relation_id uuid;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',
|
||||||
|
MESSAGE='operational area and organization must be assigned together';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id;
|
||||||
|
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and organization assets cannot receive an operational assignment';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT type.operational_role INTO area_role
|
||||||
|
FROM assets area JOIN asset_types type ON type.id=area.asset_type_id
|
||||||
|
WHERE area.id=NEW.operational_area_id AND area.information_status<>'INACTIVE' AND type.is_active=true;
|
||||||
|
SELECT type.operational_role INTO company_role
|
||||||
|
FROM assets company JOIN asset_types type ON type.id=company.asset_type_id
|
||||||
|
WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true;
|
||||||
|
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset';
|
||||||
|
END IF;
|
||||||
|
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator organization must be an active COMPANY-role asset';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT relation.id INTO active_relation_id
|
||||||
|
FROM area_company_relations relation
|
||||||
|
WHERE relation.area_id=NEW.operational_area_id
|
||||||
|
AND relation.company_id=NEW.operator_company_id
|
||||||
|
AND relation.relation_role='OPERATOR'::area_organization_role
|
||||||
|
AND relation.valid_until IS NULL
|
||||||
|
FOR KEY SHARE;
|
||||||
|
IF active_relation_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization do not have an active OPERATOR relation';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NEW.parent_id IS NULL OR NOT EXISTS (
|
||||||
|
WITH RECURSIVE ancestors AS (
|
||||||
|
SELECT id,parent_id FROM assets WHERE id=NEW.parent_id
|
||||||
|
UNION ALL
|
||||||
|
SELECT parent.id,parent.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
|
||||||
|
) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import {
|
||||||
|
loadF5InventoryAuthoritativeSource,
|
||||||
|
type F5InstallationCatalogRow,
|
||||||
|
type F5SubinstallationCatalogRow,
|
||||||
|
} from '../../reference-data/f5-authoritative-inventory-source';
|
||||||
|
|
||||||
|
type IdRow = { id: string };
|
||||||
|
type CountRow = { total: number };
|
||||||
|
|
||||||
|
const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG';
|
||||||
|
const CATALOG_CATEGORY_CODE = 'F5MODEL';
|
||||||
|
const CATALOG_SOURCE_NAME = 'final_modelov2.xlsx';
|
||||||
|
const F5_AUTO_REASON = 'F5 familia técnica: catálogo contextual automático';
|
||||||
|
const F5_SOURCE_FAMILY_COUNT = 123;
|
||||||
|
|
||||||
|
function findingKey(value: string): string {
|
||||||
|
return value.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashCode(prefix: string, value: string, length = 12): string {
|
||||||
|
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installationCode(name: string): string {
|
||||||
|
return hashCode('F5-I', findingKey(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subinstallationCode(installation: string, name: string): string {
|
||||||
|
return hashCode('F5-S', `${findingKey(installation)}|${findingKey(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subOtherCode(parentCode: string): string {
|
||||||
|
return hashCode('F5-S-OTRO', parentCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationInterface {
|
||||||
|
name = 'F5AuthoritativeInventoryCatalog1790087300000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const source = loadF5InventoryAuthoritativeSource();
|
||||||
|
if (
|
||||||
|
source.catalogSource.file !== CATALOG_SOURCE_NAME
|
||||||
|
|| source.catalogSource.sheet !== 'Hoja1'
|
||||||
|
|| source.catalogSource.sha256 !== 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155'
|
||||||
|
|| source.catalogSource.installations.length !== 14
|
||||||
|
|| source.catalogSource.subinstallations.length !== 109
|
||||||
|
) {
|
||||||
|
throw new Error('F5 inventory catalog source contract mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.catalogSource.universalFindings.length !== 3) {
|
||||||
|
throw new Error(`F5 universal finding contract mismatch: ${source.catalogSource.universalFindings.length}`);
|
||||||
|
}
|
||||||
|
const universalKeys = new Set(source.catalogSource.universalFindings.map(findingKey));
|
||||||
|
for (const required of [
|
||||||
|
'ORDEN Y LIMPIEZA',
|
||||||
|
'CARTELERIA PREVENTIVA / INFORMATIVA',
|
||||||
|
'EXTINTORES',
|
||||||
|
]) {
|
||||||
|
if (!universalKeys.has(findingKey(required))) {
|
||||||
|
throw new Error(`F5 missing authoritative universal finding: ${required}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO source_documents (
|
||||||
|
document_type,document_number,title,issuer,external_reference,notes
|
||||||
|
)
|
||||||
|
VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4)
|
||||||
|
ON CONFLICT (document_number,issuer) WHERE document_number IS NOT NULL AND issuer IS NOT NULL
|
||||||
|
DO UPDATE SET
|
||||||
|
title=EXCLUDED.title,
|
||||||
|
external_reference=EXCLUDED.external_reference,
|
||||||
|
notes=EXCLUDED.notes,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [
|
||||||
|
CATALOG_DOCUMENT_NUMBER,
|
||||||
|
CATALOG_SOURCE_NAME,
|
||||||
|
`sha256:${source.catalogSource.sha256}`,
|
||||||
|
`F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only the known historical spreadsheet catalog is superseded. Families
|
||||||
|
// created manually by DH (including source_reference NULL) remain untouched.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inventory_families
|
||||||
|
SET is_active=false,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE source_reference LIKE 'APLICACION APP%'
|
||||||
|
OR source_reference LIKE 'SYSTEM:F3.1:%'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE finding_categories
|
||||||
|
SET is_active=false,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE lower(code) IN ('app26','app26r2')
|
||||||
|
`);
|
||||||
|
|
||||||
|
const installationIds = new Map<string,string>();
|
||||||
|
for (const installation of source.catalogSource.installations) {
|
||||||
|
const familyId = await this.upsertFamily(
|
||||||
|
queryRunner,
|
||||||
|
installationCode(installation.name),
|
||||||
|
installation.name,
|
||||||
|
'INSTALLATION',
|
||||||
|
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${installation.sourceStartRow}-${installation.sourceEndRow}`,
|
||||||
|
);
|
||||||
|
installationIds.set(findingKey(installation.name),familyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const subinstallation of source.catalogSource.subinstallations) {
|
||||||
|
const parentId = installationIds.get(findingKey(subinstallation.installation));
|
||||||
|
if (!parentId) throw new Error(`F5 missing installation family ${subinstallation.installation}`);
|
||||||
|
const childId = await this.upsertFamily(
|
||||||
|
queryRunner,
|
||||||
|
subinstallationCode(subinstallation.installation,subinstallation.name),
|
||||||
|
subinstallation.name,
|
||||||
|
'SUBINSTALLATION',
|
||||||
|
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${subinstallation.sourceStartRow}-${subinstallation.sourceEndRow}${subinstallation.reference ? `|reference:${subinstallation.reference}` : ''}`,
|
||||||
|
);
|
||||||
|
await this.parentRule(queryRunner,childId,parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const installationOtherId = await this.upsertFamily(
|
||||||
|
queryRunner,
|
||||||
|
'F5-I-OTRO',
|
||||||
|
'Otro / no catalogado',
|
||||||
|
'INSTALLATION',
|
||||||
|
'F5:SYSTEM:OTHER:INSTALLATION',
|
||||||
|
);
|
||||||
|
for (const [installationKey,parentId] of installationIds) {
|
||||||
|
const parent = source.catalogSource.installations.find((item) => findingKey(item.name)===installationKey);
|
||||||
|
if (!parent) continue;
|
||||||
|
const childId = await this.upsertFamily(
|
||||||
|
queryRunner,
|
||||||
|
subOtherCode(installationCode(parent.name)),
|
||||||
|
'Otro / no catalogado',
|
||||||
|
'SUBINSTALLATION',
|
||||||
|
`F5:SYSTEM:OTHER:SUBINSTALLATION:${installationCode(parent.name)}`,
|
||||||
|
);
|
||||||
|
await this.parentRule(queryRunner,childId,parentId);
|
||||||
|
}
|
||||||
|
const rootOtherChild = await this.upsertFamily(
|
||||||
|
queryRunner,
|
||||||
|
subOtherCode('F5-I-OTRO'),
|
||||||
|
'Otro / no catalogado',
|
||||||
|
'SUBINSTALLATION',
|
||||||
|
'F5:SYSTEM:OTHER:SUBINSTALLATION:F5-I-OTRO',
|
||||||
|
);
|
||||||
|
await this.parentRule(queryRunner,rootOtherChild,installationOtherId);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_categories(code,name,sort_order,is_active)
|
||||||
|
SELECT $1::varchar,'DH · Modelo de Inventarios F5',270,true
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)=lower($1::varchar))
|
||||||
|
`, [CATALOG_CATEGORY_CODE]);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE finding_categories
|
||||||
|
SET name='DH · Modelo de Inventarios F5',sort_order=270,is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE lower(code)=lower($1::varchar)
|
||||||
|
`,[CATALOG_CATEGORY_CODE]);
|
||||||
|
const categoryId = await this.id(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
|
||||||
|
[CATALOG_CATEGORY_CODE],
|
||||||
|
'F5 finding category',
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleByKey = new Map<string,string>();
|
||||||
|
const register = (title: string): void => {
|
||||||
|
const clean = title.trim();
|
||||||
|
if (!clean || /^idem\b/i.test(clean) || findingKey(clean)==='hallazgos') return;
|
||||||
|
const itemKey = findingKey(clean);
|
||||||
|
if (!titleByKey.has(itemKey)) titleByKey.set(itemKey,clean);
|
||||||
|
};
|
||||||
|
for (const title of source.catalogSource.universalFindings) register(title);
|
||||||
|
for (const family of source.catalogSource.installations) for (const title of family.findings) register(title);
|
||||||
|
for (const family of source.catalogSource.subinstallations) for (const title of family.findings) register(title);
|
||||||
|
if (titleByKey.size !== 177) {
|
||||||
|
throw new Error(`F5 finding normalization contract mismatch: ${titleByKey.size}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemIdByKey = new Map<string,string>();
|
||||||
|
const orderedTitles = [...titleByKey.entries()].sort((a,b)=>a[1].localeCompare(b[1],'es'));
|
||||||
|
let sourceNumber=1;
|
||||||
|
for (const [itemKey,title] of orderedTitles) {
|
||||||
|
const itemCode = hashCode('F5-H',itemKey);
|
||||||
|
let itemId = await this.optionalId(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::varchar) LIMIT 1`,
|
||||||
|
[itemCode],
|
||||||
|
);
|
||||||
|
if (!itemId) {
|
||||||
|
const itemRows = (await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_items (
|
||||||
|
category_id,code,source_number,title,import_note,revision,is_active
|
||||||
|
) VALUES ($1::uuid,$2,$3,$4,$5,1,true)
|
||||||
|
RETURNING id
|
||||||
|
`,[
|
||||||
|
categoryId,itemCode,sourceNumber,title,
|
||||||
|
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
|
||||||
|
])) as IdRow[];
|
||||||
|
itemId=itemRows[0]?.id ?? null;
|
||||||
|
} else {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE finding_catalog_items
|
||||||
|
SET category_id=$2::uuid,source_number=$3,title=$4,import_note=$5,
|
||||||
|
is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1::uuid
|
||||||
|
`,[
|
||||||
|
itemId,categoryId,sourceNumber,title,
|
||||||
|
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (!itemId) throw new Error(`F5 could not create finding ${title}`);
|
||||||
|
itemIdByKey.set(itemKey,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:F5'
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add F5 mappings only. Never delete mappings created by office users or by
|
||||||
|
// historical migrations; inactive historical families simply stop being offered.
|
||||||
|
for (const family of source.catalogSource.installations) {
|
||||||
|
await this.mapFindings(
|
||||||
|
queryRunner,
|
||||||
|
installationCode(family.name),
|
||||||
|
family,
|
||||||
|
source.catalogSource.universalFindings,
|
||||||
|
itemIdByKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const family of source.catalogSource.subinstallations) {
|
||||||
|
await this.mapFindings(
|
||||||
|
queryRunner,
|
||||||
|
subinstallationCode(family.installation,family.name),
|
||||||
|
family,
|
||||||
|
source.catalogSource.universalFindings,
|
||||||
|
itemIdByKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.installFamilySyncFunctions(queryRunner);
|
||||||
|
|
||||||
|
// Keep pre-existing profile administration untouched. Yacimiento needs a
|
||||||
|
// profile only to expose OTROS because the source does not provide a family.
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
|
||||||
|
SELECT id,'F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
|
||||||
|
FROM asset_types WHERE lower(code)='yacimiento'
|
||||||
|
ON CONFLICT (asset_type_id) DO NOTHING
|
||||||
|
`);
|
||||||
|
|
||||||
|
const [counts] = (await queryRunner.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE level='INSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS installations,
|
||||||
|
COUNT(*) FILTER (WHERE level='SUBINSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS subinstallations
|
||||||
|
FROM inventory_families WHERE is_active=true
|
||||||
|
`)) as Array<{ installations:number; subinstallations:number }>;
|
||||||
|
if (Number(counts?.installations ?? 0)!==14 || Number(counts?.subinstallations ?? 0)!==109) {
|
||||||
|
throw new Error(`F5 family preload verification failed: ${JSON.stringify(counts ?? {})}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [itemCount] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM finding_catalog_items
|
||||||
|
WHERE category_id=$1::uuid AND is_active=true
|
||||||
|
`,[categoryId])) as CountRow[];
|
||||||
|
if (Number(itemCount?.total ?? 0)!==177) {
|
||||||
|
throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify every universal finding is independently attached to every one of
|
||||||
|
// the 14 + 109 source families. This intentionally avoids optional DB text
|
||||||
|
// extensions such as unaccent.
|
||||||
|
for (const universalTitle of source.catalogSource.universalFindings) {
|
||||||
|
const universalItemId = itemIdByKey.get(findingKey(universalTitle));
|
||||||
|
if (!universalItemId) throw new Error(`F5 missing universal catalog item ${universalTitle}`);
|
||||||
|
const [mappedCount] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(DISTINCT mapping.inventory_family_id)::integer AS total
|
||||||
|
FROM finding_catalog_item_inventory_families mapping
|
||||||
|
JOIN inventory_families family ON family.id=mapping.inventory_family_id
|
||||||
|
WHERE mapping.catalog_item_id=$1::uuid
|
||||||
|
AND family.is_active=true
|
||||||
|
AND family.source_reference LIKE $2
|
||||||
|
`,[universalItemId,`F5:${CATALOG_SOURCE_NAME}%`])) as CountRow[];
|
||||||
|
if (Number(mappedCount?.total ?? 0)!==F5_SOURCE_FAMILY_COUNT) {
|
||||||
|
throw new Error(`F5 universal mapping verification failed for ${universalTitle}: ${mappedCount?.total ?? 0}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const categoryId = await this.optionalId(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
|
||||||
|
[CATALOG_CATEGORY_CODE],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
const [usedFinding] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM inspection_findings finding
|
||||||
|
JOIN finding_catalog_items item ON item.id=finding.catalog_item_id
|
||||||
|
WHERE item.category_id=$1::uuid
|
||||||
|
`,[categoryId])) as CountRow[];
|
||||||
|
if (Number(usedFinding?.total ?? 0)>0) {
|
||||||
|
throw new Error('Cannot safely rollback F5 catalog: inspection findings already reference F5 catalog items');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [usedFamily] = (await queryRunner.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM assets asset
|
||||||
|
JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||||
|
WHERE family.source_reference LIKE 'F5:%'
|
||||||
|
`)) as CountRow[];
|
||||||
|
if (Number(usedFamily?.total ?? 0)>0) {
|
||||||
|
throw new Error('Cannot safely rollback F5 catalog: inventory instances already reference F5 families');
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM finding_catalog_asset_overrides
|
||||||
|
WHERE reason=$1::text
|
||||||
|
`,[F5_AUTO_REASON]);
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM finding_catalog_item_inventory_families mapping
|
||||||
|
USING finding_catalog_items item
|
||||||
|
WHERE item.id=mapping.catalog_item_id AND item.category_id=$1::uuid
|
||||||
|
`,[categoryId]);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM finding_catalog_item_versions version
|
||||||
|
USING finding_catalog_items item
|
||||||
|
WHERE item.id=version.item_id AND item.category_id=$1::uuid
|
||||||
|
`,[categoryId]);
|
||||||
|
await queryRunner.query(`DELETE FROM finding_catalog_items WHERE category_id=$1::uuid`,[categoryId]);
|
||||||
|
await queryRunner.query(`DELETE FROM finding_categories WHERE id=$1::uuid`,[categoryId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM inventory_family_parent_rules rule
|
||||||
|
USING inventory_families child
|
||||||
|
WHERE child.id=rule.child_family_id AND child.source_reference LIKE 'F5:%'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DELETE FROM inventory_families WHERE source_reference LIKE 'F5:%'`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE inventory_families
|
||||||
|
SET is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE source_reference LIKE 'APLICACION APP%'
|
||||||
|
OR source_reference LIKE 'SYSTEM:F3.1:%'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE finding_categories SET is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE lower(code)='app26r2'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM finding_catalog_asset_type_profiles profile
|
||||||
|
USING asset_types type
|
||||||
|
WHERE profile.asset_type_id=type.id
|
||||||
|
AND lower(type.code)='yacimiento'
|
||||||
|
AND profile.reason='F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM source_documents
|
||||||
|
WHERE document_number=$1::varchar AND issuer='Dirección de Hidrocarburos'
|
||||||
|
`,[CATALOG_DOCUMENT_NUMBER]);
|
||||||
|
await this.restoreF31FamilySyncFunctions(queryRunner);
|
||||||
|
|
||||||
|
// Rebuild only automatic historical overrides. Manual overrides have never
|
||||||
|
// been touched by this migration.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM finding_catalog_asset_overrides
|
||||||
|
WHERE reason LIKE 'F3.1 familia técnica:%'
|
||||||
|
`);
|
||||||
|
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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertFamily(
|
||||||
|
queryRunner: QueryRunner,
|
||||||
|
familyCode: string,
|
||||||
|
name: string,
|
||||||
|
level: 'INSTALLATION'|'SUBINSTALLATION',
|
||||||
|
sourceReference: string,
|
||||||
|
): Promise<string> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_families(
|
||||||
|
code,name,level,legacy_type_code,information_labels,source_reference,is_active
|
||||||
|
) VALUES ($1,$2,$3,NULL,'[]'::jsonb,$4,true)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL,
|
||||||
|
information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference,
|
||||||
|
is_active=true,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`,[familyCode,name,level,sourceReference]);
|
||||||
|
return this.id(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
|
||||||
|
[familyCode],
|
||||||
|
`inventory family ${familyCode}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async parentRule(queryRunner: QueryRunner,childId:string,parentId:string):Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
|
||||||
|
VALUES ($1::uuid,$2::uuid)
|
||||||
|
ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id
|
||||||
|
`,[childId,parentId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mapFindings(
|
||||||
|
queryRunner: QueryRunner,
|
||||||
|
familyCode: string,
|
||||||
|
family: F5InstallationCatalogRow|F5SubinstallationCatalogRow,
|
||||||
|
universalFindings: string[],
|
||||||
|
itemIdByKey: Map<string,string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const familyId = await this.id(
|
||||||
|
queryRunner,
|
||||||
|
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
|
||||||
|
[familyCode],
|
||||||
|
`family ${familyCode}`,
|
||||||
|
);
|
||||||
|
const mapped = new Set<string>();
|
||||||
|
for (const rawTitle of [...family.findings,...universalFindings]) {
|
||||||
|
const itemKey=findingKey(rawTitle);
|
||||||
|
if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue;
|
||||||
|
mapped.add(itemKey);
|
||||||
|
const itemId=itemIdByKey.get(itemKey);
|
||||||
|
if (!itemId) throw new Error(`F5 missing finding item ${rawTitle}`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
|
||||||
|
VALUES ($1::uuid,$2::uuid)
|
||||||
|
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
|
||||||
|
`,[itemId,familyId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installFamilySyncFunctions(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 'F% 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,
|
||||||
|
'F5 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='F5 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 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 'F% 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,
|
||||||
|
'F5 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='F5 familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DELETE FROM finding_catalog_asset_overrides
|
||||||
|
WHERE reason LIKE 'F% familia técnica:%'
|
||||||
|
`);
|
||||||
|
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,$1::text,
|
||||||
|
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=$1::text,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`,[F5_AUTO_REASON]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreF31FamilySyncFunctions(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 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 $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise<string> {
|
||||||
|
const value=await this.optionalId(queryRunner,sql,params);
|
||||||
|
if (!value) throw new Error(`F5 could not resolve ${label}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
|
||||||
|
const rows=(await queryRunner.query(sql,params)) as IdRow[];
|
||||||
|
return rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
ArrayUnique,
|
ArrayUnique,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -11,11 +12,15 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { InspectionActUrgency } from '../../database/entities';
|
||||||
|
|
||||||
export class CreateInspectionActDto {
|
export class CreateInspectionActDto {
|
||||||
@IsISO8601({ strict: true })
|
@IsISO8601({ strict: true })
|
||||||
occurredAt!: string;
|
occurredAt!: string;
|
||||||
|
|
||||||
|
@IsEnum(InspectionActUrgency)
|
||||||
|
urgency!: InspectionActUrgency;
|
||||||
|
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
ArrayUnique,
|
ArrayUnique,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
IsISO8601,
|
IsISO8601,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
@@ -11,12 +12,17 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { InspectionActUrgency } from '../../database/entities';
|
||||||
|
|
||||||
export class UpdateInspectionActDto {
|
export class UpdateInspectionActDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601({ strict: true })
|
@IsISO8601({ strict: true })
|
||||||
occurredAt?: string;
|
occurredAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(InspectionActUrgency)
|
||||||
|
urgency?: InspectionActUrgency;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
DocumentSequenceType,
|
DocumentSequenceType,
|
||||||
InspectionAct,
|
InspectionAct,
|
||||||
InspectionActStatus,
|
InspectionActStatus,
|
||||||
|
InspectionActUrgency,
|
||||||
|
InspectionDeadlineBasis,
|
||||||
|
InspectionDeadlineDayType,
|
||||||
InspectionActVersionEvent,
|
InspectionActVersionEvent,
|
||||||
InspectionVisit,
|
InspectionVisit,
|
||||||
InspectionVisitStatus,
|
InspectionVisitStatus,
|
||||||
@@ -41,7 +44,6 @@ interface ActAsset {
|
|||||||
interface ActVisitSummary {
|
interface ActVisitSummary {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
|
||||||
status: InspectionVisitStatus;
|
status: InspectionVisitStatus;
|
||||||
actualStartedAt: Date | null;
|
actualStartedAt: Date | null;
|
||||||
}
|
}
|
||||||
@@ -72,6 +74,17 @@ export interface InspectionActListItem {
|
|||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
observations: string | null;
|
observations: string | null;
|
||||||
|
urgency: InspectionActUrgency;
|
||||||
|
deadlineDays: number | null;
|
||||||
|
deadlineDayType: InspectionDeadlineDayType | null;
|
||||||
|
deadlineBasis: InspectionDeadlineBasis | null;
|
||||||
|
deadlineBaseAt: Date | null;
|
||||||
|
deadlineAt: Date | null;
|
||||||
|
lockedAt: Date | null;
|
||||||
|
lockedBy: string | null;
|
||||||
|
lockedSha256: string | null;
|
||||||
|
sealedAt: Date | null;
|
||||||
|
sealedBy: string | null;
|
||||||
currentVersion: number;
|
currentVersion: number;
|
||||||
cancellationReason: string | null;
|
cancellationReason: string | null;
|
||||||
closedAt: Date | null;
|
closedAt: Date | null;
|
||||||
@@ -137,7 +150,6 @@ export class InspectionActsService {
|
|||||||
act.code ILIKE ${search}
|
act.code ILIKE ${search}
|
||||||
OR act.title ILIKE ${search}
|
OR act.title ILIKE ${search}
|
||||||
OR visit.code ILIKE ${search}
|
OR visit.code ILIKE ${search}
|
||||||
OR visit.title ILIKE ${search}
|
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM inspection_act_assets search_link
|
FROM inspection_act_assets search_link
|
||||||
@@ -313,13 +325,16 @@ export class InspectionActsService {
|
|||||||
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);
|
||||||
const actNumber = await this.allocateNumber(manager, actYear);
|
const actNumber = await this.allocateNumber(manager, actYear);
|
||||||
if (actNumber > 999999) {
|
if (actNumber > 99999) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED',
|
code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED',
|
||||||
message: 'La numeración anual de actas agotó su rango disponible',
|
message: 'La numeración anual de actas agotó su rango disponible',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const code = `ACTA-${actYear}-${String(actNumber).padStart(6, '0')}`;
|
const [dateRow] = (await manager.query(`
|
||||||
|
SELECT TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS date_part
|
||||||
|
`, [occurredAt])) as Array<{ date_part: string }>;
|
||||||
|
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
|
||||||
const act = manager.getRepository(InspectionAct).create({
|
const act = manager.getRepository(InspectionAct).create({
|
||||||
visitId,
|
visitId,
|
||||||
actYear,
|
actYear,
|
||||||
@@ -330,6 +345,17 @@ export class InspectionActsService {
|
|||||||
title: dto.title,
|
title: dto.title,
|
||||||
summary: dto.summary,
|
summary: dto.summary,
|
||||||
observations: dto.observations ?? null,
|
observations: dto.observations ?? null,
|
||||||
|
urgency: dto.urgency,
|
||||||
|
deadlineDays: null,
|
||||||
|
deadlineDayType: null,
|
||||||
|
deadlineBasis: null,
|
||||||
|
deadlineBaseAt: null,
|
||||||
|
deadlineAt: null,
|
||||||
|
lockedAt: null,
|
||||||
|
lockedBy: null,
|
||||||
|
lockedSha256: null,
|
||||||
|
sealedAt: null,
|
||||||
|
sealedBy: null,
|
||||||
currentVersion: 0,
|
currentVersion: 0,
|
||||||
cancellationReason: null,
|
cancellationReason: null,
|
||||||
createdBy: principal.userId,
|
createdBy: principal.userId,
|
||||||
@@ -384,6 +410,7 @@ export class InspectionActsService {
|
|||||||
await this.assertVisitAssets(manager, visit.id, nextAssetIds);
|
await this.assertVisitAssets(manager, visit.id, nextAssetIds);
|
||||||
const before = await this.loadView(manager, id);
|
const before = await this.loadView(manager, id);
|
||||||
if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt;
|
if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt;
|
||||||
|
if (dto.urgency !== undefined) act.urgency = dto.urgency;
|
||||||
if (dto.title !== undefined) act.title = dto.title;
|
if (dto.title !== undefined) act.title = dto.title;
|
||||||
if (dto.summary !== undefined) act.summary = dto.summary;
|
if (dto.summary !== undefined) act.summary = dto.summary;
|
||||||
if (dto.observations !== undefined) act.observations = dto.observations;
|
if (dto.observations !== undefined) act.observations = dto.observations;
|
||||||
@@ -462,7 +489,6 @@ export class InspectionActsService {
|
|||||||
JSONB_BUILD_OBJECT(
|
JSONB_BUILD_OBJECT(
|
||||||
'id', visit.id,
|
'id', visit.id,
|
||||||
'code', visit.code,
|
'code', visit.code,
|
||||||
'title', visit.title,
|
|
||||||
'status', visit.status,
|
'status', visit.status,
|
||||||
'actualStartedAt', visit.actual_started_at
|
'actualStartedAt', visit.actual_started_at
|
||||||
) AS visit,
|
) AS visit,
|
||||||
@@ -474,6 +500,17 @@ export class InspectionActsService {
|
|||||||
act.title,
|
act.title,
|
||||||
act.summary,
|
act.summary,
|
||||||
act.observations,
|
act.observations,
|
||||||
|
act.urgency,
|
||||||
|
act.deadline_days AS "deadlineDays",
|
||||||
|
act.deadline_day_type AS "deadlineDayType",
|
||||||
|
act.deadline_basis AS "deadlineBasis",
|
||||||
|
act.deadline_base_at AS "deadlineBaseAt",
|
||||||
|
act.deadline_at AS "deadlineAt",
|
||||||
|
act.locked_at AS "lockedAt",
|
||||||
|
act.locked_by AS "lockedBy",
|
||||||
|
act.locked_sha256 AS "lockedSha256",
|
||||||
|
act.sealed_at AS "sealedAt",
|
||||||
|
act.sealed_by AS "sealedBy",
|
||||||
act.current_version AS "currentVersion",
|
act.current_version AS "currentVersion",
|
||||||
act.cancellation_reason AS "cancellationReason",
|
act.cancellation_reason AS "cancellationReason",
|
||||||
act.closed_at AS "closedAt",
|
act.closed_at AS "closedAt",
|
||||||
@@ -645,7 +682,7 @@ export class InspectionActsService {
|
|||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS',
|
code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS',
|
||||||
message: 'La inspección ya tiene un acta en borrador; preparala o cancelala antes de crear otra',
|
message: 'La inspección ya tiene un acta en borrador; finalizala o cancelala antes de crear otra',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -665,7 +702,7 @@ export class InspectionActsService {
|
|||||||
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
|
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
code: 'INSPECTION_ACT_ASSET_INVALID',
|
code: 'INSPECTION_ACT_ASSET_INVALID',
|
||||||
message: 'Cada activo del acta debe estar incluido en la visita',
|
message: 'Cada inventario del acta debe estar incluido en la inspección',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -758,6 +795,15 @@ export class InspectionActsService {
|
|||||||
'title', act.title,
|
'title', act.title,
|
||||||
'summary', act.summary,
|
'summary', act.summary,
|
||||||
'observations', act.observations,
|
'observations', act.observations,
|
||||||
|
'urgency', act.urgency,
|
||||||
|
'deadlineDays', act.deadline_days,
|
||||||
|
'deadlineDayType', act.deadline_day_type,
|
||||||
|
'deadlineBasis', act.deadline_basis,
|
||||||
|
'deadlineBaseAt', act.deadline_base_at,
|
||||||
|
'deadlineAt', act.deadline_at,
|
||||||
|
'lockedAt', act.locked_at,
|
||||||
|
'lockedSha256', act.locked_sha256,
|
||||||
|
'sealedAt', act.sealed_at,
|
||||||
'currentVersion', act.current_version,
|
'currentVersion', act.current_version,
|
||||||
'cancellationReason', act.cancellation_reason,
|
'cancellationReason', act.cancellation_reason,
|
||||||
'createdBy', act.created_by,
|
'createdBy', act.created_by,
|
||||||
@@ -765,7 +811,6 @@ export class InspectionActsService {
|
|||||||
'visit', JSONB_BUILD_OBJECT(
|
'visit', JSONB_BUILD_OBJECT(
|
||||||
'id', visit.id,
|
'id', visit.id,
|
||||||
'code', visit.code,
|
'code', visit.code,
|
||||||
'title', visit.title,
|
|
||||||
'status', visit.status,
|
'status', visit.status,
|
||||||
'scopeAssetId', visit.scope_asset_id,
|
'scopeAssetId', visit.scope_asset_id,
|
||||||
'actualStartedAt', visit.actual_started_at
|
'actualStartedAt', visit.actual_started_at
|
||||||
@@ -804,6 +849,15 @@ export class InspectionActsService {
|
|||||||
title: act.title,
|
title: act.title,
|
||||||
summary: act.summary,
|
summary: act.summary,
|
||||||
observations: act.observations,
|
observations: act.observations,
|
||||||
|
urgency: act.urgency,
|
||||||
|
deadlineDays: act.deadlineDays,
|
||||||
|
deadlineDayType: act.deadlineDayType,
|
||||||
|
deadlineBasis: act.deadlineBasis,
|
||||||
|
deadlineBaseAt: act.deadlineBaseAt,
|
||||||
|
deadlineAt: act.deadlineAt,
|
||||||
|
lockedAt: act.lockedAt,
|
||||||
|
lockedSha256: act.lockedSha256,
|
||||||
|
sealedAt: act.sealedAt,
|
||||||
currentVersion: act.currentVersion,
|
currentVersion: act.currentVersion,
|
||||||
cancellationReason: act.cancellationReason,
|
cancellationReason: act.cancellationReason,
|
||||||
closedAt: act.closedAt,
|
closedAt: act.closedAt,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
|
import { SkipCsrf } from '../auth/decorators/skip-csrf.decorator';
|
||||||
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { CompanySignatureInviteService } from './company-signature-invite.service';
|
||||||
|
import {
|
||||||
|
CreateCompanySignatureInviteDto,
|
||||||
|
PublicCompanyRefusalDto,
|
||||||
|
PublicCompanySignatureDto,
|
||||||
|
} from './dto/company-signature-invite.dto';
|
||||||
|
import {
|
||||||
|
MAX_INSPECTION_SIGNATURE_BYTES,
|
||||||
|
type UploadedInspectionSignatureFile,
|
||||||
|
} from './inspection-signature-file';
|
||||||
|
|
||||||
|
@Controller('inspection-acts/:actId/company-signature-invitations')
|
||||||
|
export class CompanySignatureInviteController {
|
||||||
|
constructor(private readonly invites: CompanySignatureInviteService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('inspection_closure.sign')
|
||||||
|
create(
|
||||||
|
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||||
|
@Body() dto: CreateCompanySignatureInviteDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.invites.create(actId, dto, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@SkipCsrf()
|
||||||
|
@Controller('public/company-signatures')
|
||||||
|
export class PublicCompanySignatureController {
|
||||||
|
constructor(private readonly invites: CompanySignatureInviteService) {}
|
||||||
|
|
||||||
|
@Get(':token')
|
||||||
|
view(@Param('token') token: string) {
|
||||||
|
return this.invites.view(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':token/sign')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
|
||||||
|
}))
|
||||||
|
sign(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@Body() dto: PublicCompanySignatureDto,
|
||||||
|
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.invites.sign(token, dto, file, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':token/refuse')
|
||||||
|
refuse(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@Body() dto: PublicCompanyRefusalDto,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.invites.refuse(token, dto, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { isAbsolute, parse, resolve } from 'node:path';
|
||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
GoneException,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import {
|
||||||
|
AuditSource,
|
||||||
|
InspectionActSignatureSource,
|
||||||
|
InspectionActSignatureStatus,
|
||||||
|
InspectionActSignerType,
|
||||||
|
InspectionCompanySignatureManifestation,
|
||||||
|
} from '../database/entities';
|
||||||
|
import { SmtpDeliveryService } from '../inspection-reports/smtp-delivery.service';
|
||||||
|
import { sha256CanonicalJson } from './canonical-json';
|
||||||
|
import type {
|
||||||
|
CreateCompanySignatureInviteDto,
|
||||||
|
PublicCompanyRefusalDto,
|
||||||
|
PublicCompanySignatureDto,
|
||||||
|
} from './dto/company-signature-invite.dto';
|
||||||
|
import {
|
||||||
|
inspectInspectionSignatureFile,
|
||||||
|
type UploadedInspectionSignatureFile,
|
||||||
|
} from './inspection-signature-file';
|
||||||
|
|
||||||
|
const REMOTE_CONSENT_VERSION = 'F4-1';
|
||||||
|
const REMOTE_COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del Acta y que esta firma se incorpora como constancia de recepción, sin implicar aceptación de los Hallazgos.';
|
||||||
|
|
||||||
|
interface InviteContext {
|
||||||
|
id: string;
|
||||||
|
actId: string;
|
||||||
|
tokenSha256: string;
|
||||||
|
recipientEmail: string;
|
||||||
|
recipientName: string | null;
|
||||||
|
recipientDocumentType: string | null;
|
||||||
|
recipientDocumentNumber: string | null;
|
||||||
|
recipientPosition: string | null;
|
||||||
|
status: 'PENDING' | 'USED' | 'REVOKED' | 'EXPIRED';
|
||||||
|
expiresAt: Date;
|
||||||
|
sentAt: Date | null;
|
||||||
|
usedAt: Date | null;
|
||||||
|
createdBy: string;
|
||||||
|
actCode: string;
|
||||||
|
actStatus: string;
|
||||||
|
lockedSha256: string | null;
|
||||||
|
lockedAt: Date | null;
|
||||||
|
inspectionCode: string;
|
||||||
|
lockedSnapshot: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompanySignatureInviteService {
|
||||||
|
private readonly signatureRoot: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly smtp: SmtpDeliveryService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {
|
||||||
|
const configured = config.get<string>('INSPECTION_SIGNATURE_ROOT')
|
||||||
|
?? '/app/storage/asset-media/inspection-signatures';
|
||||||
|
if (!isAbsolute(configured)) throw new Error('INSPECTION_SIGNATURE_ROOT must be an absolute path');
|
||||||
|
this.signatureRoot = resolve(configured);
|
||||||
|
if (this.signatureRoot === parse(this.signatureRoot).root) {
|
||||||
|
throw new Error('INSPECTION_SIGNATURE_ROOT cannot be the filesystem root');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
actId: string,
|
||||||
|
dto: CreateCompanySignatureInviteDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const token = randomBytes(32).toString('base64url');
|
||||||
|
const tokenSha256 = this.hashToken(token);
|
||||||
|
const expiresAt = new Date(Date.now() + (dto.expiresInDays ?? 7) * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const created = await this.dataSource.transaction(async (manager) => {
|
||||||
|
await this.assertActorAssigned(manager, actId, principal);
|
||||||
|
const [context] = await manager.query(`
|
||||||
|
SELECT
|
||||||
|
act.id,
|
||||||
|
act.code,
|
||||||
|
act.status,
|
||||||
|
act.locked_sha256 AS "lockedSha256",
|
||||||
|
responsible.full_name AS "fullName",
|
||||||
|
responsible.document_type AS "documentType",
|
||||||
|
responsible.document_number AS "documentNumber",
|
||||||
|
responsible.position,
|
||||||
|
responsible.email AS "responsibleEmail",
|
||||||
|
visit.code AS "inspectionCode",
|
||||||
|
(
|
||||||
|
SELECT profile.notification_email
|
||||||
|
FROM inspection_act_assets link
|
||||||
|
JOIN assets inventory ON inventory.id=link.asset_id
|
||||||
|
JOIN asset_types inventory_type ON inventory_type.id=inventory.asset_type_id
|
||||||
|
JOIN assets company ON company.id=COALESCE(
|
||||||
|
inventory.operator_company_id,
|
||||||
|
CASE WHEN inventory_type.operational_role='COMPANY' THEN inventory.id END
|
||||||
|
)
|
||||||
|
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||||
|
WHERE link.act_id=act.id AND link.included=true
|
||||||
|
AND profile.notification_email IS NOT NULL
|
||||||
|
ORDER BY company.id
|
||||||
|
LIMIT 1
|
||||||
|
) AS "companyEmail"
|
||||||
|
FROM inspection_acts act
|
||||||
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
|
LEFT JOIN inspection_act_responsibles responsible ON responsible.act_id=act.id
|
||||||
|
WHERE act.id=$1
|
||||||
|
FOR UPDATE OF act
|
||||||
|
`, [actId]) as Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
lockedSha256: string | null;
|
||||||
|
fullName: string | null;
|
||||||
|
documentType: string | null;
|
||||||
|
documentNumber: string | null;
|
||||||
|
position: string | null;
|
||||||
|
responsibleEmail: string | null;
|
||||||
|
inspectionCode: string;
|
||||||
|
companyEmail: string | null;
|
||||||
|
}>;
|
||||||
|
if (!context) {
|
||||||
|
throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta no encontrada' });
|
||||||
|
}
|
||||||
|
if (context.status !== 'LOCKED' || !context.lockedSha256) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'COMPANY_SIGNATURE_INVITE_ACT_NOT_LOCKED',
|
||||||
|
message: 'La invitación de firma sólo puede emitirse para un Acta BLOQUEADA',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (await this.hasCompanyOutcome(manager, actId)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED',
|
||||||
|
message: 'La manifestación de la empresa ya fue registrada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const recipientEmail = dto.recipientEmail
|
||||||
|
?? context.responsibleEmail?.toLowerCase()
|
||||||
|
?? context.companyEmail?.toLowerCase()
|
||||||
|
?? null;
|
||||||
|
if (!recipientEmail) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'COMPANY_SIGNATURE_EMAIL_REQUIRED',
|
||||||
|
message: 'No hay email de responsable ni email institucional de empresa; indicá un destinatario',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE inspection_act_company_signature_invites
|
||||||
|
SET status='REVOKED',revoked_at=CURRENT_TIMESTAMP,revoked_by=$2,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE act_id=$1 AND status='PENDING'
|
||||||
|
`, [actId, principal.userId]);
|
||||||
|
|
||||||
|
const [invite] = await manager.query(`
|
||||||
|
INSERT INTO inspection_act_company_signature_invites(
|
||||||
|
act_id,token_sha256,recipient_email,recipient_name,recipient_document_type,
|
||||||
|
recipient_document_number,recipient_position,expires_at,created_by
|
||||||
|
) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||||
|
RETURNING id,act_id AS "actId",recipient_email AS "recipientEmail",
|
||||||
|
expires_at AS "expiresAt",created_at AS "createdAt"
|
||||||
|
`, [
|
||||||
|
actId,
|
||||||
|
tokenSha256,
|
||||||
|
recipientEmail,
|
||||||
|
context.fullName,
|
||||||
|
context.documentType,
|
||||||
|
context.documentNumber,
|
||||||
|
context.position,
|
||||||
|
expiresAt,
|
||||||
|
principal.userId,
|
||||||
|
]) as Array<{
|
||||||
|
id: string;
|
||||||
|
actId: string;
|
||||||
|
recipientEmail: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: 'INSPECTION_ACT_COMPANY_SIGNATURE_INVITE_CREATED',
|
||||||
|
entityType: 'inspection_act_company_signature_invite',
|
||||||
|
entityId: invite.id,
|
||||||
|
afterData: {
|
||||||
|
actId,
|
||||||
|
actCode: context.code,
|
||||||
|
recipientEmail,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
metadata: { tokenStoredAsHashOnly: true },
|
||||||
|
}, manager);
|
||||||
|
return { ...invite, actCode: context.code, inspectionCode: context.inspectionCode };
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicBase = this.config.get<string>('COMPANY_SIGNATURE_PUBLIC_BASE_URL')?.trim().replace(/\/$/, '') ?? null;
|
||||||
|
const publicUrl = publicBase ? `${publicBase}?token=${encodeURIComponent(token)}` : null;
|
||||||
|
let emailSent = false;
|
||||||
|
let deliveryError: string | null = null;
|
||||||
|
|
||||||
|
if (!publicUrl) {
|
||||||
|
deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada';
|
||||||
|
} else if (!(await this.smtp.configured())) {
|
||||||
|
deliveryError = 'SMTP no configurado';
|
||||||
|
} else {
|
||||||
|
const body = [
|
||||||
|
`Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`,
|
||||||
|
`Inspección: ${created.inspectionCode}.`,
|
||||||
|
'',
|
||||||
|
'El enlace permite firmar en conformidad, firmar en disidencia o registrar una negativa a firmar.',
|
||||||
|
'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.',
|
||||||
|
'',
|
||||||
|
`Enlace seguro: ${publicUrl}`,
|
||||||
|
`Válido hasta: ${new Date(created.expiresAt).toLocaleString('es-AR')}`,
|
||||||
|
].join('\n');
|
||||||
|
try {
|
||||||
|
await this.smtp.send({
|
||||||
|
to: created.recipientEmail,
|
||||||
|
subject: `DH Inspección · Firma de ${created.actCode}`,
|
||||||
|
text: body,
|
||||||
|
attachment: {
|
||||||
|
filename: `${created.actCode}-instrucciones.txt`,
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
content: Buffer.from(body, 'utf8'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emailSent = true;
|
||||||
|
await this.dataSource.query(`
|
||||||
|
UPDATE inspection_act_company_signature_invites
|
||||||
|
SET sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
`, [created.id]);
|
||||||
|
} catch (error) {
|
||||||
|
deliveryError = error instanceof Error ? error.message.slice(0, 500) : 'No se pudo enviar el email';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
actId: created.actId,
|
||||||
|
actCode: created.actCode,
|
||||||
|
recipientEmail: created.recipientEmail,
|
||||||
|
expiresAt: created.expiresAt,
|
||||||
|
emailSent,
|
||||||
|
deliveryError,
|
||||||
|
publicUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async view(token: string) {
|
||||||
|
const invite = await this.resolveToken(this.dataSource.manager, token, false);
|
||||||
|
const locked = invite.lockedSnapshot ?? {};
|
||||||
|
const act = this.record(locked.act);
|
||||||
|
const inventories = this.records(locked.inventories);
|
||||||
|
const findings = this.records(locked.findings).map((finding) => ({
|
||||||
|
id: finding.id,
|
||||||
|
code: finding.code,
|
||||||
|
title: finding.title,
|
||||||
|
description: finding.description,
|
||||||
|
legalBasis: finding.legalBasis ?? null,
|
||||||
|
severity: finding.severity ?? null,
|
||||||
|
isRecurrence: finding.isRecurrence === true,
|
||||||
|
recurrenceOfFindingId: finding.recurrenceOfFindingId ?? null,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
invitation: {
|
||||||
|
id: invite.id,
|
||||||
|
recipientEmail: invite.recipientEmail,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
},
|
||||||
|
act: {
|
||||||
|
code: invite.actCode,
|
||||||
|
inspectionCode: invite.inspectionCode,
|
||||||
|
lockedAt: invite.lockedAt,
|
||||||
|
lockedSha256: invite.lockedSha256,
|
||||||
|
urgency: act.urgency ?? null,
|
||||||
|
summary: act.summary ?? null,
|
||||||
|
observations: act.observations ?? null,
|
||||||
|
},
|
||||||
|
responsibleDefaults: {
|
||||||
|
fullName: invite.recipientName,
|
||||||
|
documentType: invite.recipientDocumentType,
|
||||||
|
documentNumber: invite.recipientDocumentNumber,
|
||||||
|
position: invite.recipientPosition,
|
||||||
|
},
|
||||||
|
inventories: inventories.map((inventory) => ({
|
||||||
|
id: inventory.id,
|
||||||
|
code: inventory.code,
|
||||||
|
name: inventory.name,
|
||||||
|
typeName: inventory.typeName ?? inventory.typeCode ?? null,
|
||||||
|
})),
|
||||||
|
findings,
|
||||||
|
consent: REMOTE_COMPANY_CONSENT,
|
||||||
|
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async sign(
|
||||||
|
token: string,
|
||||||
|
dto: PublicCompanySignatureDto,
|
||||||
|
file: UploadedInspectionSignatureFile | undefined,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
if (!dto.consentAccepted) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'COMPANY_SIGNATURE_CONSENT_REQUIRED',
|
||||||
|
message: 'Debe aceptarse la constancia antes de firmar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const inspected = inspectInspectionSignatureFile(file);
|
||||||
|
const id = randomUUID();
|
||||||
|
const storedName = `${id}.png`;
|
||||||
|
const filePath = resolve(this.signatureRoot, storedName);
|
||||||
|
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||||
|
await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 });
|
||||||
|
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.dataSource.transaction(async (manager) => {
|
||||||
|
const invite = await this.resolveToken(manager, token, true);
|
||||||
|
await this.assertNoCompanyOutcome(manager, invite.actId);
|
||||||
|
const signedAt = new Date();
|
||||||
|
const manifestation = dto.manifestation === 'DISSENT'
|
||||||
|
? InspectionCompanySignatureManifestation.DISSENT
|
||||||
|
: InspectionCompanySignatureManifestation.CONFORMITY;
|
||||||
|
const statement = manifestation === InspectionCompanySignatureManifestation.DISSENT
|
||||||
|
? dto.statement?.trim() ?? null
|
||||||
|
: null;
|
||||||
|
const payload = {
|
||||||
|
invitationId: invite.id,
|
||||||
|
actId: invite.actId,
|
||||||
|
lockedSha256: invite.lockedSha256,
|
||||||
|
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
|
||||||
|
signerName: dto.fullName,
|
||||||
|
documentType: dto.documentType,
|
||||||
|
documentNumber: dto.documentNumber,
|
||||||
|
position: dto.position,
|
||||||
|
status: InspectionActSignatureStatus.SIGNED,
|
||||||
|
companyManifestation: manifestation,
|
||||||
|
companyStatement: statement,
|
||||||
|
imageSha256,
|
||||||
|
consentText: REMOTE_COMPANY_CONSENT,
|
||||||
|
consentVersion: REMOTE_CONSENT_VERSION,
|
||||||
|
signedAt: signedAt.toISOString(),
|
||||||
|
source: InspectionActSignatureSource.WEB,
|
||||||
|
};
|
||||||
|
const signaturePayloadSha256 = sha256CanonicalJson(payload);
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inspection_act_signatures(
|
||||||
|
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,
|
||||||
|
position,status,company_manifestation,company_statement,original_name,stored_name,
|
||||||
|
mime_type,size_bytes,image_sha256,consent_text,consent_version,consent_accepted_at,
|
||||||
|
signed_at,device_label,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at
|
||||||
|
) VALUES(
|
||||||
|
$1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'SIGNED',$7,$8,$9,$10,$11,$12,$13,
|
||||||
|
$14,$15,$16,$16,'Firma remota por enlace seguro','WEB',$17,$18,$19,$16
|
||||||
|
)
|
||||||
|
`, [
|
||||||
|
id,
|
||||||
|
invite.actId,
|
||||||
|
dto.fullName,
|
||||||
|
dto.documentType,
|
||||||
|
dto.documentNumber,
|
||||||
|
dto.position,
|
||||||
|
manifestation,
|
||||||
|
statement,
|
||||||
|
inspected.originalName,
|
||||||
|
storedName,
|
||||||
|
inspected.mimeType,
|
||||||
|
file!.buffer.length,
|
||||||
|
imageSha256,
|
||||||
|
REMOTE_COMPANY_CONSENT,
|
||||||
|
REMOTE_CONSENT_VERSION,
|
||||||
|
signedAt,
|
||||||
|
invite.lockedSha256,
|
||||||
|
signaturePayloadSha256,
|
||||||
|
invite.createdBy,
|
||||||
|
]);
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE inspection_act_company_signature_invites
|
||||||
|
SET status='USED',used_at=$2,updated_at=$2
|
||||||
|
WHERE id=$1
|
||||||
|
`, [invite.id, signedAt]);
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'INSPECTION_ACT_COMPANY_REMOTE_SIGNATURE_RECORDED',
|
||||||
|
entityType: 'inspection_act_signature',
|
||||||
|
entityId: id,
|
||||||
|
source: AuditSource.WEB,
|
||||||
|
actorUserId: null,
|
||||||
|
actorUsername: null,
|
||||||
|
requestId: request.requestId,
|
||||||
|
ip: request.ip ?? null,
|
||||||
|
userAgent: request.get('user-agent') ?? null,
|
||||||
|
afterData: payload,
|
||||||
|
metadata: {
|
||||||
|
invitationId: invite.id,
|
||||||
|
recipientEmail: invite.recipientEmail,
|
||||||
|
immutable: true,
|
||||||
|
signaturePayloadSha256,
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
return { actCode: invite.actCode, signatureId: id, manifestation };
|
||||||
|
});
|
||||||
|
return { ok: true, ...result };
|
||||||
|
} catch (error) {
|
||||||
|
await unlink(filePath).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refuse(
|
||||||
|
token: string,
|
||||||
|
dto: PublicCompanyRefusalDto,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const invite = await this.resolveToken(manager, token, true);
|
||||||
|
await this.assertNoCompanyOutcome(manager, invite.actId);
|
||||||
|
const createdAt = new Date();
|
||||||
|
const id = randomUUID();
|
||||||
|
const payload = {
|
||||||
|
invitationId: invite.id,
|
||||||
|
actId: invite.actId,
|
||||||
|
lockedSha256: invite.lockedSha256,
|
||||||
|
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
|
||||||
|
signerName: dto.fullName,
|
||||||
|
documentType: dto.documentType,
|
||||||
|
documentNumber: dto.documentNumber,
|
||||||
|
position: dto.position,
|
||||||
|
status: InspectionActSignatureStatus.REFUSED,
|
||||||
|
reason: dto.reason,
|
||||||
|
source: InspectionActSignatureSource.WEB,
|
||||||
|
createdAt: createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
const signaturePayloadSha256 = sha256CanonicalJson(payload);
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inspection_act_signatures(
|
||||||
|
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,
|
||||||
|
position,status,reason,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at
|
||||||
|
) VALUES($1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'REFUSED',$7,'WEB',$8,$9,$10,$11)
|
||||||
|
`, [
|
||||||
|
id,
|
||||||
|
invite.actId,
|
||||||
|
dto.fullName,
|
||||||
|
dto.documentType,
|
||||||
|
dto.documentNumber,
|
||||||
|
dto.position,
|
||||||
|
dto.reason,
|
||||||
|
invite.lockedSha256,
|
||||||
|
signaturePayloadSha256,
|
||||||
|
invite.createdBy,
|
||||||
|
createdAt,
|
||||||
|
]);
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE inspection_act_company_signature_invites
|
||||||
|
SET status='USED',used_at=$2,updated_at=$2
|
||||||
|
WHERE id=$1
|
||||||
|
`, [invite.id, createdAt]);
|
||||||
|
await this.audit.record({
|
||||||
|
action: 'INSPECTION_ACT_COMPANY_REMOTE_REFUSAL_RECORDED',
|
||||||
|
entityType: 'inspection_act_signature',
|
||||||
|
entityId: id,
|
||||||
|
source: AuditSource.WEB,
|
||||||
|
actorUserId: null,
|
||||||
|
actorUsername: null,
|
||||||
|
requestId: request.requestId,
|
||||||
|
ip: request.ip ?? null,
|
||||||
|
userAgent: request.get('user-agent') ?? null,
|
||||||
|
afterData: payload,
|
||||||
|
metadata: {
|
||||||
|
invitationId: invite.id,
|
||||||
|
recipientEmail: invite.recipientEmail,
|
||||||
|
immutable: true,
|
||||||
|
signaturePayloadSha256,
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
return { ok: true, actCode: invite.actCode, refusalId: id };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveToken(
|
||||||
|
manager: EntityManager,
|
||||||
|
token: string,
|
||||||
|
lock: boolean,
|
||||||
|
): Promise<InviteContext> {
|
||||||
|
if (!/^[A-Za-z0-9_-]{40,120}$/.test(token)) throw this.invalidInvite();
|
||||||
|
const tokenSha256 = this.hashToken(token);
|
||||||
|
const [invite] = await manager.query(`
|
||||||
|
SELECT
|
||||||
|
invite.id,
|
||||||
|
invite.act_id AS "actId",
|
||||||
|
invite.token_sha256 AS "tokenSha256",
|
||||||
|
invite.recipient_email AS "recipientEmail",
|
||||||
|
invite.recipient_name AS "recipientName",
|
||||||
|
invite.recipient_document_type AS "recipientDocumentType",
|
||||||
|
invite.recipient_document_number AS "recipientDocumentNumber",
|
||||||
|
invite.recipient_position AS "recipientPosition",
|
||||||
|
invite.status,
|
||||||
|
invite.expires_at AS "expiresAt",
|
||||||
|
invite.sent_at AS "sentAt",
|
||||||
|
invite.used_at AS "usedAt",
|
||||||
|
invite.created_by AS "createdBy",
|
||||||
|
act.code AS "actCode",
|
||||||
|
act.status AS "actStatus",
|
||||||
|
act.locked_sha256 AS "lockedSha256",
|
||||||
|
act.locked_at AS "lockedAt",
|
||||||
|
visit.code AS "inspectionCode",
|
||||||
|
closure.prepared_snapshot AS "lockedSnapshot"
|
||||||
|
FROM inspection_act_company_signature_invites invite
|
||||||
|
JOIN inspection_acts act ON act.id=invite.act_id
|
||||||
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
|
JOIN inspection_act_closures closure ON closure.act_id=act.id
|
||||||
|
WHERE invite.token_sha256=$1
|
||||||
|
${lock ? 'FOR UPDATE OF invite,act' : ''}
|
||||||
|
`, [tokenSha256]) as InviteContext[];
|
||||||
|
if (!invite) throw this.invalidInvite();
|
||||||
|
if (invite.status !== 'PENDING') {
|
||||||
|
throw new GoneException({
|
||||||
|
code: 'COMPANY_SIGNATURE_INVITE_NOT_ACTIVE',
|
||||||
|
message: invite.status === 'USED'
|
||||||
|
? 'Este enlace ya fue utilizado'
|
||||||
|
: 'Este enlace ya no está vigente',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (new Date(invite.expiresAt).getTime() <= Date.now()) {
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE inspection_act_company_signature_invites
|
||||||
|
SET status='EXPIRED',updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1 AND status='PENDING'
|
||||||
|
`, [invite.id]);
|
||||||
|
throw new GoneException({
|
||||||
|
code: 'COMPANY_SIGNATURE_INVITE_EXPIRED',
|
||||||
|
message: 'El enlace de firma venció. Solicitá una nueva invitación.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (invite.actStatus !== 'LOCKED' || !invite.lockedSha256 || !invite.lockedSnapshot) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'COMPANY_SIGNATURE_ACT_NOT_AVAILABLE',
|
||||||
|
message: 'El Acta ya no está disponible para manifestación remota',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertActorAssigned(
|
||||||
|
manager: EntityManager,
|
||||||
|
actId: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
): Promise<void> {
|
||||||
|
if (principal.permissions.includes('inspections.manage')) return;
|
||||||
|
const [row] = await manager.query(`
|
||||||
|
SELECT 1
|
||||||
|
FROM inspection_acts act
|
||||||
|
JOIN inspection_visit_members member ON member.visit_id=act.visit_id
|
||||||
|
WHERE act.id=$1 AND member.user_id=$2 AND member.included=true
|
||||||
|
LIMIT 1
|
||||||
|
`, [actId, principal.userId]) as unknown[];
|
||||||
|
if (!row) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'COMPANY_SIGNATURE_INVITE_NOT_ASSIGNED',
|
||||||
|
message: 'Sólo un Inspector asignado puede emitir la invitación',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertNoCompanyOutcome(manager: EntityManager, actId: string): Promise<void> {
|
||||||
|
if (await this.hasCompanyOutcome(manager, actId)) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED',
|
||||||
|
message: 'La manifestación de la empresa ya fue registrada',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise<boolean> {
|
||||||
|
const rows = await manager.query(`
|
||||||
|
SELECT 1 FROM inspection_act_signatures
|
||||||
|
WHERE act_id=$1 AND signer_type='COMPANY_RESPONSIBLE'
|
||||||
|
LIMIT 1
|
||||||
|
`, [actId]) as unknown[];
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token, 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private invalidInvite(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'COMPANY_SIGNATURE_INVITE_NOT_FOUND',
|
||||||
|
message: 'El enlace de firma no es válido',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown>
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private records(value: unknown): Array<Record<string, unknown>> {
|
||||||
|
return Array.isArray(value) ? value.map((item) => this.record(item)) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private storageError(): InternalServerErrorException {
|
||||||
|
return new InternalServerErrorException({
|
||||||
|
code: 'COMPANY_SIGNATURE_STORAGE_ERROR',
|
||||||
|
message: 'No se pudo almacenar la firma remota',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCompanySignatureInviteDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined)
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(320)
|
||||||
|
recipientEmail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(30)
|
||||||
|
expiresInDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicCompanySignatureDto {
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(200)
|
||||||
|
fullName!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER'])
|
||||||
|
documentType!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(40)
|
||||||
|
documentNumber!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(200)
|
||||||
|
position!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['CONFORMITY', 'DISSENT'])
|
||||||
|
manifestation!: 'CONFORMITY' | 'DISSENT';
|
||||||
|
|
||||||
|
@ValidateIf((value: PublicCompanySignatureDto) => value.manifestation === 'DISSENT')
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
@MaxLength(8000)
|
||||||
|
statement?: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => value === true || value === 'true')
|
||||||
|
@IsBoolean()
|
||||||
|
consentAccepted!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicCompanyRefusalDto {
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(200)
|
||||||
|
fullName!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER'])
|
||||||
|
documentType!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(40)
|
||||||
|
documentNumber!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(200)
|
||||||
|
position!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
@MaxLength(8000)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
@@ -48,9 +48,9 @@ export class InspectionClosingController {
|
|||||||
return this.closing.upsertResponsible(actId, dto, principal, request);
|
return this.closing.upsertResponsible(actId, dto, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('ready')
|
@Post('lock')
|
||||||
@RequirePermissions('inspection_closure.prepare')
|
@RequirePermissions('inspection_closure.prepare')
|
||||||
ready(
|
lock(
|
||||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
@Req() request: RequestWithContext,
|
@Req() request: RequestWithContext,
|
||||||
@@ -58,16 +58,6 @@ export class InspectionClosingController {
|
|||||||
return this.closing.prepare(actId, principal, request);
|
return this.closing.prepare(actId, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('reopen')
|
|
||||||
@RequirePermissions('inspection_closure.prepare')
|
|
||||||
reopen(
|
|
||||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
) {
|
|
||||||
return this.closing.reopen(actId, principal, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('signatures/inspector')
|
@Post('signatures/inspector')
|
||||||
@RequirePermissions('inspection_closure.sign')
|
@RequirePermissions('inspection_closure.sign')
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
@@ -109,9 +99,9 @@ export class InspectionClosingController {
|
|||||||
return this.closing.recordCompanyOutcome(actId, dto, principal, request);
|
return this.closing.recordCompanyOutcome(actId, dto, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('close')
|
@Post('seal')
|
||||||
@RequirePermissions('inspection_closure.close')
|
@RequirePermissions('inspection_closure.close')
|
||||||
close(
|
seal(
|
||||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||||
@Body() dto: CloseInspectionActDto,
|
@Body() dto: CloseInspectionActDto,
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
|
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
|
||||||
|
import {
|
||||||
|
CompanySignatureInviteController,
|
||||||
|
PublicCompanySignatureController,
|
||||||
|
} from './company-signature-invite.controller';
|
||||||
|
import { CompanySignatureInviteService } from './company-signature-invite.service';
|
||||||
import {
|
import {
|
||||||
InspectionClosingController,
|
InspectionClosingController,
|
||||||
InspectionSignatureContentController,
|
InspectionSignatureContentController,
|
||||||
@@ -9,7 +14,12 @@ import { InspectionClosingService } from './inspection-closing.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule, InspectionReportsModule],
|
imports: [AuditModule, InspectionReportsModule],
|
||||||
controllers: [InspectionClosingController, InspectionSignatureContentController],
|
controllers: [
|
||||||
providers: [InspectionClosingService],
|
InspectionClosingController,
|
||||||
|
InspectionSignatureContentController,
|
||||||
|
CompanySignatureInviteController,
|
||||||
|
PublicCompanySignatureController,
|
||||||
|
],
|
||||||
|
providers: [InspectionClosingService, CompanySignatureInviteService],
|
||||||
})
|
})
|
||||||
export class InspectionClosingModule {}
|
export class InspectionClosingModule {}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetFindingRecurrenceDto {
|
||||||
|
@IsUUID('4')
|
||||||
|
recurrenceOfFindingId!: string;
|
||||||
|
}
|
||||||
@@ -1,41 +1,65 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
|
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
|
||||||
import { FindingCatalogService } from './finding-catalog.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class F3FindingCatalogResolverService {
|
export class F3FindingCatalogResolverService {
|
||||||
constructor(
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
private readonly legacyCatalog: FindingCatalogService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
|
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
|
||||||
const [asset] = await this.dataSource.query(`
|
const [asset] = await this.dataSource.query(`
|
||||||
SELECT asset.id,asset.code,asset.name,
|
SELECT asset.id,asset.code,asset.name,
|
||||||
|
type.code AS "typeCode",
|
||||||
asset.inventory_family_id AS "familyId",
|
asset.inventory_family_id AS "familyId",
|
||||||
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel"
|
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel",
|
||||||
|
family.is_active AS "familyActive"
|
||||||
FROM assets asset
|
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 inventory_families family ON family.id=asset.inventory_family_id
|
||||||
WHERE asset.id=$1::uuid
|
WHERE asset.id=$1::uuid
|
||||||
`, [assetId]) as Array<{
|
`, [assetId]) as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
typeCode: string;
|
||||||
familyId: string | null;
|
familyId: string | null;
|
||||||
familyCode: string | null;
|
familyCode: string | null;
|
||||||
familyName: string | null;
|
familyName: string | null;
|
||||||
familyLevel: string | null;
|
familyLevel: string | null;
|
||||||
|
familyActive: boolean | null;
|
||||||
}>;
|
}>;
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
||||||
}
|
}
|
||||||
if (!asset.familyId) return this.legacyCatalog.listApplicableForAsset(assetId, query);
|
|
||||||
|
const other = {
|
||||||
|
enabled: true,
|
||||||
|
code: 'OTHER',
|
||||||
|
label: 'OTROS',
|
||||||
|
help: 'Usalo cuando el Hallazgo no exista en el catálogo aplicable. Se enviará una propuesta a revisión de oficina.',
|
||||||
|
};
|
||||||
|
|
||||||
|
// F5: no se mezclan catálogos históricos. Yacimiento no posee familia técnica
|
||||||
|
// en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS.
|
||||||
|
if (!asset.familyId || asset.familyActive !== true) {
|
||||||
|
return {
|
||||||
|
asset: { id: asset.id, code: asset.code, name: asset.name },
|
||||||
|
inventoryFamily: null,
|
||||||
|
typeConfigured: false,
|
||||||
|
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
|
||||||
|
? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.'
|
||||||
|
: 'F5 · El elemento todavía no tiene una clasificación técnica activa.',
|
||||||
|
categories: [],
|
||||||
|
items: [],
|
||||||
|
other,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
'mapping.inventory_family_id=$1::uuid',
|
'mapping.inventory_family_id=$1::uuid',
|
||||||
'item.is_active=true',
|
'item.is_active=true',
|
||||||
'category.is_active=true',
|
'category.is_active=true',
|
||||||
|
"lower(category.code)='f5model'",
|
||||||
'merge_record.source_item_id IS NULL',
|
'merge_record.source_item_id IS NULL',
|
||||||
];
|
];
|
||||||
const params: unknown[] = [asset.familyId];
|
const params: unknown[] = [asset.familyId];
|
||||||
@@ -83,15 +107,10 @@ export class F3FindingCatalogResolverService {
|
|||||||
level: asset.familyLevel,
|
level: asset.familyLevel,
|
||||||
},
|
},
|
||||||
typeConfigured: true,
|
typeConfigured: true,
|
||||||
configurationReason: `F3.1 · Catálogo del Excel asociado a ${asset.familyName ?? 'la familia técnica'}`,
|
configurationReason: `F5 · Catálogo final_modelov2.xlsx asociado a ${asset.familyName ?? 'la clasificación técnica'}`,
|
||||||
categories,
|
categories,
|
||||||
items,
|
items,
|
||||||
other: {
|
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,53 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
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 { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto';
|
||||||
|
import { FindingRecurrenceService } from './finding-recurrence.service';
|
||||||
|
|
||||||
|
@Controller('inspection-findings')
|
||||||
|
export class FindingRecurrenceController {
|
||||||
|
constructor(private readonly recurrence: FindingRecurrenceService) {}
|
||||||
|
|
||||||
|
@Get(':id/recurrence')
|
||||||
|
@RequirePermissions('inspection_findings.read')
|
||||||
|
state(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.recurrence.state(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/recurrence-candidates')
|
||||||
|
@RequirePermissions('inspection_findings.read')
|
||||||
|
candidates(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.recurrence.candidates(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/recurrence')
|
||||||
|
@RequirePermissions('inspection_findings.update')
|
||||||
|
link(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: SetFindingRecurrenceDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.recurrence.link(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/recurrence')
|
||||||
|
@RequirePermissions('inspection_findings.update')
|
||||||
|
clear(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.recurrence.clear(id, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
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 { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
||||||
|
import type { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto';
|
||||||
|
import { InspectionFindingsService } from './inspection-findings.service';
|
||||||
|
import type { InspectionFindingView } from './inspection-findings.service';
|
||||||
|
|
||||||
|
interface FindingRecurrenceContext {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
catalogItemId: string | null;
|
||||||
|
title: string;
|
||||||
|
createdAt: Date;
|
||||||
|
actId: string;
|
||||||
|
actStatus: string;
|
||||||
|
visitId: string;
|
||||||
|
visitStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FindingRecurrenceState {
|
||||||
|
isRecurrence: boolean;
|
||||||
|
recurrenceOfFindingId: string | null;
|
||||||
|
recurrenceOfFindingCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FindingWithRecurrenceView = InspectionFindingView & FindingRecurrenceState;
|
||||||
|
|
||||||
|
function comparable(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FindingRecurrenceService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly findings: InspectionFindingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async state(id: string): Promise<FindingRecurrenceState> {
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
finding.is_recurrence AS "isRecurrence",
|
||||||
|
finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
||||||
|
antecedent.code AS "recurrenceOfFindingCode"
|
||||||
|
FROM inspection_findings finding
|
||||||
|
LEFT JOIN inspection_findings antecedent ON antecedent.id = finding.recurrence_of_finding_id
|
||||||
|
WHERE finding.id = $1
|
||||||
|
`, [id]) as FindingRecurrenceState[];
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_FINDING_NOT_FOUND',
|
||||||
|
message: 'Hallazgo no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async candidates(id: string) {
|
||||||
|
const current = await this.requireFinding(this.dataSource.manager, id, false);
|
||||||
|
const rows = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
finding.id,finding.code,finding.title,finding.description,finding.status,
|
||||||
|
finding.catalog_item_id AS "catalogItemId",finding.created_at AS "createdAt",
|
||||||
|
act.id AS "actId",act.code AS "actCode",act.occurred_at AS "actOccurredAt",
|
||||||
|
visit.id AS "visitId",visit.code AS "visitCode",visit.status AS "visitStatus"
|
||||||
|
FROM inspection_findings finding
|
||||||
|
JOIN inspection_acts act ON act.id=finding.act_id
|
||||||
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
|
WHERE finding.asset_id=$1
|
||||||
|
AND finding.id<>$2
|
||||||
|
AND finding.status<>'VOIDED'
|
||||||
|
AND finding.created_at<$3
|
||||||
|
AND (
|
||||||
|
($4::uuid IS NOT NULL AND finding.catalog_item_id=$4::uuid)
|
||||||
|
OR ($4::uuid IS NULL AND lower(btrim(finding.title))=lower(btrim($5)))
|
||||||
|
)
|
||||||
|
ORDER BY finding.created_at DESC,finding.id DESC
|
||||||
|
LIMIT 20
|
||||||
|
`, [current.assetId, current.id, current.createdAt, current.catalogItemId, current.title]);
|
||||||
|
return {
|
||||||
|
findingId: current.id,
|
||||||
|
assetId: current.assetId,
|
||||||
|
hasCandidates: rows.length > 0,
|
||||||
|
data: rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async link(
|
||||||
|
id: string,
|
||||||
|
dto: SetFindingRecurrenceDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<FindingWithRecurrenceView> {
|
||||||
|
assertMobileInspector(principal);
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const current = await this.requireFinding(manager, id, true);
|
||||||
|
this.assertEditable(current);
|
||||||
|
await this.assertActorAssigned(manager, current.visitId, principal);
|
||||||
|
if (id === dto.recurrenceOfFindingId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'FINDING_CANNOT_RECUR_FROM_SELF',
|
||||||
|
message: 'Un Hallazgo no puede ser reincidencia de sí mismo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const previous = await this.requireFinding(manager, dto.recurrenceOfFindingId, false);
|
||||||
|
if (previous.assetId !== current.assetId) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FINDING_RECURRENCE_DIFFERENT_INVENTORY',
|
||||||
|
message: 'La reincidencia debe referir a un Hallazgo anterior del mismo Inventario',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (new Date(previous.createdAt).getTime() >= new Date(current.createdAt).getTime()) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FINDING_RECURRENCE_NOT_PREVIOUS',
|
||||||
|
message: 'El Hallazgo de referencia debe ser anterior al actual',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sameCatalog = current.catalogItemId && previous.catalogItemId
|
||||||
|
? current.catalogItemId === previous.catalogItemId
|
||||||
|
: current.catalogItemId === previous.catalogItemId;
|
||||||
|
const sameTitle = comparable(current.title) === comparable(previous.title);
|
||||||
|
if (!sameCatalog && !sameTitle) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FINDING_RECURRENCE_NOT_EQUIVALENT',
|
||||||
|
message: 'El antecedente seleccionado no corresponde al mismo tipo de Hallazgo',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [before] = await manager.query(`
|
||||||
|
SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId"
|
||||||
|
FROM inspection_findings WHERE id=$1 FOR UPDATE
|
||||||
|
`, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>;
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE inspection_findings
|
||||||
|
SET is_recurrence=true,recurrence_of_finding_id=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
`, [id, previous.id, principal.userId]);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_FINDING_UPDATED,
|
||||||
|
entityType: 'inspection_finding',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: before ?? { isRecurrence: false, recurrenceOfFindingId: null },
|
||||||
|
afterData: {
|
||||||
|
isRecurrence: true,
|
||||||
|
recurrenceOfFindingId: previous.id,
|
||||||
|
recurrenceOfFindingCode: await this.codeForFinding(manager, previous.id),
|
||||||
|
},
|
||||||
|
metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'LINKED' },
|
||||||
|
}, manager);
|
||||||
|
});
|
||||||
|
const [finding, recurrence] = await Promise.all([
|
||||||
|
this.findings.getById(id),
|
||||||
|
this.state(id),
|
||||||
|
]);
|
||||||
|
return { ...finding, ...recurrence };
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(
|
||||||
|
id: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
): Promise<FindingWithRecurrenceView> {
|
||||||
|
assertMobileInspector(principal);
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const current = await this.requireFinding(manager, id, true);
|
||||||
|
this.assertEditable(current);
|
||||||
|
await this.assertActorAssigned(manager, current.visitId, principal);
|
||||||
|
const [before] = await manager.query(`
|
||||||
|
SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId"
|
||||||
|
FROM inspection_findings WHERE id=$1 FOR UPDATE
|
||||||
|
`, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>;
|
||||||
|
if (!before?.isRecurrence && !before?.recurrenceOfFindingId) return;
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE inspection_findings
|
||||||
|
SET is_recurrence=false,recurrence_of_finding_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
`, [id, principal.userId]);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_FINDING_UPDATED,
|
||||||
|
entityType: 'inspection_finding',
|
||||||
|
entityId: id,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: { isRecurrence: false, recurrenceOfFindingId: null },
|
||||||
|
metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'CLEARED' },
|
||||||
|
}, manager);
|
||||||
|
});
|
||||||
|
const [finding, recurrence] = await Promise.all([
|
||||||
|
this.findings.getById(id),
|
||||||
|
this.state(id),
|
||||||
|
]);
|
||||||
|
return { ...finding, ...recurrence };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireFinding(
|
||||||
|
manager: EntityManager,
|
||||||
|
id: string,
|
||||||
|
lock: boolean,
|
||||||
|
): Promise<FindingRecurrenceContext> {
|
||||||
|
const [row] = await manager.query(`
|
||||||
|
SELECT finding.id,finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",
|
||||||
|
finding.title,finding.created_at AS "createdAt",finding.act_id AS "actId",
|
||||||
|
act.status AS "actStatus",visit.id AS "visitId",visit.status AS "visitStatus"
|
||||||
|
FROM inspection_findings finding
|
||||||
|
JOIN inspection_acts act ON act.id=finding.act_id
|
||||||
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
|
WHERE finding.id=$1
|
||||||
|
${lock ? 'FOR UPDATE OF finding,act,visit' : ''}
|
||||||
|
`, [id]) as FindingRecurrenceContext[];
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_FINDING_NOT_FOUND',
|
||||||
|
message: 'Hallazgo no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertEditable(finding: FindingRecurrenceContext): void {
|
||||||
|
if (finding.actStatus !== 'DRAFT' || finding.visitStatus !== 'IN_PROGRESS') {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'FINDING_RECURRENCE_IMMUTABLE',
|
||||||
|
message: 'La reincidencia sólo puede definirse mientras el Acta está en BORRADOR y la Inspección en curso',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertActorAssigned(
|
||||||
|
manager: EntityManager,
|
||||||
|
visitId: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
): Promise<void> {
|
||||||
|
if (principal.permissions.includes('inspections.manage')) return;
|
||||||
|
const [row] = await manager.query(`
|
||||||
|
SELECT 1 FROM inspection_visit_members
|
||||||
|
WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1
|
||||||
|
`, [visitId, principal.userId]) as unknown[];
|
||||||
|
if (!row) {
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'INSPECTION_NOT_ASSIGNED',
|
||||||
|
message: 'La Inspección no está asignada al usuario actual',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async codeForFinding(manager: EntityManager, id: string): Promise<string | null> {
|
||||||
|
const [row] = await manager.query('SELECT code FROM inspection_findings WHERE id=$1', [id]) as Array<{ code: string }>;
|
||||||
|
return row?.code ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import type { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
|
||||||
|
|
||||||
|
export interface InspectionFindingWorklistCounters {
|
||||||
|
open: number;
|
||||||
|
withoutControlDate: number;
|
||||||
|
toVerify: number;
|
||||||
|
verificationOverdue: number;
|
||||||
|
verificationNext30Days: number;
|
||||||
|
readyToClose: number;
|
||||||
|
closed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionFindingWorklistItem {
|
||||||
|
id: string;
|
||||||
|
actId: string;
|
||||||
|
assetId: string;
|
||||||
|
code: string;
|
||||||
|
status: 'OPEN' | 'CLOSED' | 'VOIDED';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
severity: number | null;
|
||||||
|
nextControlOn: string | null;
|
||||||
|
closedAt: Date | null;
|
||||||
|
closureNotes: string | null;
|
||||||
|
asset: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
commonName: string | null;
|
||||||
|
typeName: string;
|
||||||
|
operatorCompany: { id: string; code: string; name: string } | null;
|
||||||
|
operationalArea: { id: string; code: string; name: string } | null;
|
||||||
|
};
|
||||||
|
document: {
|
||||||
|
actId: string;
|
||||||
|
actCode: string;
|
||||||
|
actStatus: string;
|
||||||
|
visitId: string;
|
||||||
|
visitCode: string;
|
||||||
|
visitStatus: string;
|
||||||
|
};
|
||||||
|
verificationVisit: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: string;
|
||||||
|
plannedStartAt: Date | null;
|
||||||
|
} | null;
|
||||||
|
latestVerification: {
|
||||||
|
visitId: string;
|
||||||
|
visitCode: string;
|
||||||
|
visitStatus: string;
|
||||||
|
targetControlOn: string | null;
|
||||||
|
outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null;
|
||||||
|
resultNotes: string | null;
|
||||||
|
verifiedAt: Date | null;
|
||||||
|
resultRecordedAt: Date | null;
|
||||||
|
rescheduledControlOn: string | null;
|
||||||
|
evidenceCount: number;
|
||||||
|
} | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionFindingWorklistPage {
|
||||||
|
data: InspectionFindingWorklistItem[];
|
||||||
|
meta: { page: number; pageSize: number; total: number; totalPages: number };
|
||||||
|
counters: InspectionFindingWorklistCounters;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InspectionFindingWorklistService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async list(query: ListInspectionFindingsQueryDto): Promise<InspectionFindingWorklistPage> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const pageSize = query.pageSize ?? 25;
|
||||||
|
const workflow = query.workflow ?? 'OPEN';
|
||||||
|
const values: unknown[] = [];
|
||||||
|
const contextFilters: string[] = [];
|
||||||
|
const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`;
|
||||||
|
const latestOutcome = `(SELECT latest_verification.outcome
|
||||||
|
FROM inspection_finding_verification_visits latest_verification
|
||||||
|
WHERE latest_verification.finding_id = finding.id
|
||||||
|
AND latest_verification.outcome IS NOT NULL
|
||||||
|
ORDER BY latest_verification.result_recorded_at DESC NULLS LAST,
|
||||||
|
latest_verification.created_at DESC,
|
||||||
|
latest_verification.id DESC
|
||||||
|
LIMIT 1)`;
|
||||||
|
const latestVisitStatus = `(SELECT latest_visit.status
|
||||||
|
FROM inspection_finding_verification_visits latest_verification
|
||||||
|
INNER JOIN inspection_visits latest_visit ON latest_visit.id = latest_verification.visit_id
|
||||||
|
WHERE latest_verification.finding_id = finding.id
|
||||||
|
AND latest_verification.outcome IS NOT NULL
|
||||||
|
ORDER BY latest_verification.result_recorded_at DESC NULLS LAST,
|
||||||
|
latest_verification.created_at DESC,
|
||||||
|
latest_verification.id DESC
|
||||||
|
LIMIT 1)`;
|
||||||
|
|
||||||
|
const add = (value: unknown): string => {
|
||||||
|
values.push(value);
|
||||||
|
return `$${values.length}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (query.search) {
|
||||||
|
const parameter = add(`%${query.search}%`);
|
||||||
|
contextFilters.push(`(
|
||||||
|
finding.code ILIKE ${parameter}
|
||||||
|
OR finding.title ILIKE ${parameter}
|
||||||
|
OR finding.description ILIKE ${parameter}
|
||||||
|
OR asset.code ILIKE ${parameter}
|
||||||
|
OR asset.name ILIKE ${parameter}
|
||||||
|
OR company.name ILIKE ${parameter}
|
||||||
|
OR area.name ILIKE ${parameter}
|
||||||
|
OR act.code ILIKE ${parameter}
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
if (query.companyId) contextFilters.push(`company.id = ${add(query.companyId)}::uuid`);
|
||||||
|
if (query.areaId) contextFilters.push(`area.id = ${add(query.areaId)}::uuid`);
|
||||||
|
if (query.inspectorId) {
|
||||||
|
const inspector = add(query.inspectorId);
|
||||||
|
contextFilters.push(`(
|
||||||
|
visit.lead_inspector_user_id = ${inspector}::uuid
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM inspection_visit_members member_filter
|
||||||
|
WHERE member_filter.visit_id = visit.id
|
||||||
|
AND member_filter.included = true
|
||||||
|
AND member_filter.user_id = ${inspector}::uuid
|
||||||
|
)
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
if (query.dateFrom) contextFilters.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
|
||||||
|
if (query.dateTo) contextFilters.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
|
||||||
|
|
||||||
|
const workflowFilters: string[] = [];
|
||||||
|
if (workflow === 'OPEN') workflowFilters.push(`finding.status = 'OPEN'`);
|
||||||
|
if (workflow === 'TO_SCHEDULE_VERIFICATION') {
|
||||||
|
workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`);
|
||||||
|
}
|
||||||
|
if (workflow === 'TO_VERIFY') {
|
||||||
|
workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`);
|
||||||
|
}
|
||||||
|
if (workflow === 'VERIFICATION_OVERDUE') {
|
||||||
|
workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on < ${today} AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`);
|
||||||
|
}
|
||||||
|
if (workflow === 'READY_TO_CLOSE') {
|
||||||
|
workflowFilters.push(`finding.status = 'OPEN' AND ${latestOutcome} = 'RESOLVED' AND ${latestVisitStatus} = 'CLOSED'`);
|
||||||
|
}
|
||||||
|
if (workflow === 'CLOSED') workflowFilters.push(`finding.status = 'CLOSED'`);
|
||||||
|
if (workflow === 'WAITING_COMPANY' || workflow === 'COMPANY_OVERDUE') {
|
||||||
|
// Compatibilidad de URL histórica: estos estados dejaron de existir en F4.
|
||||||
|
workflowFilters.push(`finding.status = 'OPEN'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const joins = `
|
||||||
|
INNER JOIN inspection_acts act ON act.id = finding.act_id
|
||||||
|
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||||
|
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||||
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||||
|
LEFT JOIN assets company ON company.id = asset.operator_company_id
|
||||||
|
LEFT JOIN assets area ON area.id = asset.operational_area_id
|
||||||
|
`;
|
||||||
|
const filters = [...contextFilters, ...workflowFilters];
|
||||||
|
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
const contextWhere = contextFilters.length ? `WHERE ${contextFilters.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
const [countRow] = await this.dataSource.query(`
|
||||||
|
SELECT COUNT(*)::integer AS total
|
||||||
|
FROM inspection_findings finding
|
||||||
|
${joins}
|
||||||
|
${where}
|
||||||
|
`, values) as Array<{ total: number }>;
|
||||||
|
const total = Number(countRow?.total ?? 0);
|
||||||
|
|
||||||
|
const listValues = [...values, pageSize, (page - 1) * pageSize];
|
||||||
|
const limitParameter = `$${values.length + 1}`;
|
||||||
|
const offsetParameter = `$${values.length + 2}`;
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
finding.id,
|
||||||
|
finding.act_id AS "actId",
|
||||||
|
finding.asset_id AS "assetId",
|
||||||
|
finding.code,
|
||||||
|
finding.status,
|
||||||
|
finding.title,
|
||||||
|
finding.description,
|
||||||
|
finding.severity,
|
||||||
|
finding.next_control_on AS "nextControlOn",
|
||||||
|
finding.closed_at AS "closedAt",
|
||||||
|
finding.closure_notes AS "closureNotes",
|
||||||
|
JSONB_BUILD_OBJECT(
|
||||||
|
'id', asset.id,
|
||||||
|
'code', asset.code,
|
||||||
|
'name', asset.name,
|
||||||
|
'commonName', asset.common_name,
|
||||||
|
'typeName', asset_type.name,
|
||||||
|
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id', company.id, 'code', company.code, 'name', company.name
|
||||||
|
) END,
|
||||||
|
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id', area.id, 'code', area.code, 'name', area.name
|
||||||
|
) END
|
||||||
|
) AS asset,
|
||||||
|
JSONB_BUILD_OBJECT(
|
||||||
|
'actId', act.id,
|
||||||
|
'actCode', act.code,
|
||||||
|
'actStatus', act.status,
|
||||||
|
'visitId', visit.id,
|
||||||
|
'visitCode', visit.code,
|
||||||
|
'visitStatus', visit.status
|
||||||
|
) AS document,
|
||||||
|
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'id', verification_visit.id,
|
||||||
|
'code', verification_visit.code,
|
||||||
|
'status', verification_visit.status,
|
||||||
|
'plannedStartAt', verification_visit.planned_start_at
|
||||||
|
) END AS "verificationVisit",
|
||||||
|
CASE WHEN latest_verification.visit_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
|
'visitId', latest_verification.visit_id,
|
||||||
|
'visitCode', latest_verification.visit_code,
|
||||||
|
'visitStatus', latest_verification.visit_status,
|
||||||
|
'targetControlOn', latest_verification.target_control_on,
|
||||||
|
'outcome', latest_verification.outcome,
|
||||||
|
'resultNotes', latest_verification.result_notes,
|
||||||
|
'verifiedAt', latest_verification.verified_at,
|
||||||
|
'resultRecordedAt', latest_verification.result_recorded_at,
|
||||||
|
'rescheduledControlOn', latest_verification.rescheduled_control_on,
|
||||||
|
'evidenceCount', latest_verification.evidence_count
|
||||||
|
) END AS "latestVerification",
|
||||||
|
finding.created_at AS "createdAt",
|
||||||
|
finding.updated_at AS "updatedAt"
|
||||||
|
FROM inspection_findings finding
|
||||||
|
${joins}
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT verification_target.id, verification_target.code,
|
||||||
|
verification_target.status, verification_target.planned_start_at
|
||||||
|
FROM inspection_finding_verification_visits verification_link
|
||||||
|
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
|
||||||
|
WHERE verification_link.finding_id = finding.id
|
||||||
|
AND verification_target.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')
|
||||||
|
ORDER BY verification_link.created_at DESC, verification_link.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) verification_visit ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
verification_link.visit_id,
|
||||||
|
verification_target.code AS visit_code,
|
||||||
|
verification_target.status AS visit_status,
|
||||||
|
verification_link.target_control_on,
|
||||||
|
verification_link.outcome,
|
||||||
|
verification_link.result_notes,
|
||||||
|
verification_link.verified_at,
|
||||||
|
verification_link.result_recorded_at,
|
||||||
|
verification_link.rescheduled_control_on,
|
||||||
|
(SELECT COUNT(*)::integer
|
||||||
|
FROM inspection_finding_evidence evidence
|
||||||
|
WHERE evidence.finding_id = finding.id
|
||||||
|
AND evidence.verification_visit_id = verification_link.visit_id
|
||||||
|
AND evidence.purpose = 'VERIFICATION') AS evidence_count
|
||||||
|
FROM inspection_finding_verification_visits verification_link
|
||||||
|
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
|
||||||
|
WHERE verification_link.finding_id = finding.id
|
||||||
|
ORDER BY COALESCE(verification_link.result_recorded_at, verification_link.created_at) DESC,
|
||||||
|
verification_link.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest_verification ON true
|
||||||
|
${where}
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN finding.status = 'OPEN' THEN 0 ELSE 1 END,
|
||||||
|
COALESCE(finding.next_control_on, '9999-12-31'::date),
|
||||||
|
finding.updated_at DESC,
|
||||||
|
finding.code
|
||||||
|
LIMIT ${limitParameter} OFFSET ${offsetParameter}
|
||||||
|
`, listValues) as InspectionFindingWorklistItem[];
|
||||||
|
|
||||||
|
const [counterRow] = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE finding.status='OPEN')::integer AS "open",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE finding.status='OPEN'
|
||||||
|
AND finding.next_control_on IS NULL
|
||||||
|
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
|
||||||
|
)::integer AS "withoutControlDate",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE finding.status='OPEN'
|
||||||
|
AND finding.next_control_on IS NOT NULL
|
||||||
|
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
|
||||||
|
)::integer AS "toVerify",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE finding.status='OPEN'
|
||||||
|
AND finding.next_control_on IS NOT NULL
|
||||||
|
AND finding.next_control_on < ${today}
|
||||||
|
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
|
||||||
|
)::integer AS "verificationOverdue",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE finding.status='OPEN'
|
||||||
|
AND finding.next_control_on BETWEEN ${today} AND ${today} + 30
|
||||||
|
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
|
||||||
|
)::integer AS "verificationNext30Days",
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE finding.status='OPEN'
|
||||||
|
AND ${latestOutcome}='RESOLVED'
|
||||||
|
AND ${latestVisitStatus}='CLOSED'
|
||||||
|
)::integer AS "readyToClose",
|
||||||
|
COUNT(*) FILTER (WHERE finding.status='CLOSED')::integer AS "closed"
|
||||||
|
FROM inspection_findings finding
|
||||||
|
${joins}
|
||||||
|
${contextWhere}
|
||||||
|
`, values) as InspectionFindingWorklistCounters[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
counters: {
|
||||||
|
open: Number(counterRow?.open ?? 0),
|
||||||
|
withoutControlDate: Number(counterRow?.withoutControlDate ?? 0),
|
||||||
|
toVerify: Number(counterRow?.toVerify ?? 0),
|
||||||
|
verificationOverdue: Number(counterRow?.verificationOverdue ?? 0),
|
||||||
|
verificationNext30Days: Number(counterRow?.verificationNext30Days ?? 0),
|
||||||
|
readyToClose: Number(counterRow?.readyToClose ?? 0),
|
||||||
|
closed: Number(counterRow?.closed ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,12 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c
|
|||||||
import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
|
import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
|
||||||
import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
|
import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
|
||||||
import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
|
import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
|
||||||
import { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto';
|
|
||||||
import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
|
import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
|
||||||
|
import { FindingRecurrenceService } from './finding-recurrence.service';
|
||||||
|
import type { FindingRecurrenceState } from './finding-recurrence.service';
|
||||||
|
import { InspectionFindingWorklistService } from './inspection-finding-worklist.service';
|
||||||
import { InspectionFindingsService } from './inspection-findings.service';
|
import { InspectionFindingsService } from './inspection-findings.service';
|
||||||
|
import type { InspectionFindingView } from './inspection-findings.service';
|
||||||
|
|
||||||
@Controller('inspection-acts/:actId/findings')
|
@Controller('inspection-acts/:actId/findings')
|
||||||
export class InspectionActFindingsController {
|
export class InspectionActFindingsController {
|
||||||
@@ -43,18 +46,28 @@ export class InspectionActFindingsController {
|
|||||||
|
|
||||||
@Controller('inspection-findings')
|
@Controller('inspection-findings')
|
||||||
export class InspectionFindingsController {
|
export class InspectionFindingsController {
|
||||||
constructor(private readonly findings: InspectionFindingsService) {}
|
constructor(
|
||||||
|
private readonly findings: InspectionFindingsService,
|
||||||
|
private readonly worklist: InspectionFindingWorklistService,
|
||||||
|
private readonly recurrence: FindingRecurrenceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions('inspection_findings.read')
|
@RequirePermissions('inspection_findings.read')
|
||||||
list(@Query() query: ListInspectionFindingsQueryDto) {
|
list(@Query() query: ListInspectionFindingsQueryDto) {
|
||||||
return this.findings.listGlobal(query);
|
return this.worklist.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermissions('inspection_findings.read')
|
@RequirePermissions('inspection_findings.read')
|
||||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
async get(
|
||||||
return this.findings.getById(id);
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
): Promise<InspectionFindingView & FindingRecurrenceState> {
|
||||||
|
const [finding, recurrence] = await Promise.all([
|
||||||
|
this.findings.getById(id),
|
||||||
|
this.recurrence.state(id),
|
||||||
|
]);
|
||||||
|
return { ...finding, ...recurrence };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/verification-history')
|
@Get(':id/verification-history')
|
||||||
@@ -84,15 +97,4 @@ export class InspectionFindingsController {
|
|||||||
) {
|
) {
|
||||||
return this.findings.close(id, dto, principal, request);
|
return this.findings.close(id, dto, principal, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/follow-up')
|
|
||||||
@RequirePermissions('inspection_findings.follow_up')
|
|
||||||
updateFollowUp(
|
|
||||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
|
||||||
@Body() dto: UpdateInspectionFindingFollowUpDto,
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
) {
|
|
||||||
return this.findings.updateFollowUp(id, dto, principal, request);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { FindingCatalogController } from './finding-catalog.controller';
|
|||||||
import { FindingCatalogMergeService } from './finding-catalog-merge.service';
|
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 { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
|
||||||
|
import { FindingRecurrenceController } from './finding-recurrence.controller';
|
||||||
|
import { FindingRecurrenceService } from './finding-recurrence.service';
|
||||||
|
import { InspectionFindingWorklistService } from './inspection-finding-worklist.service';
|
||||||
import {
|
import {
|
||||||
InspectionActFindingsController,
|
InspectionActFindingsController,
|
||||||
InspectionFindingsController,
|
InspectionFindingsController,
|
||||||
@@ -22,6 +25,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
|||||||
FindingCatalogController,
|
FindingCatalogController,
|
||||||
InspectionActFindingsController,
|
InspectionActFindingsController,
|
||||||
InspectionFindingsController,
|
InspectionFindingsController,
|
||||||
|
FindingRecurrenceController,
|
||||||
InspectionFindingEvidenceController,
|
InspectionFindingEvidenceController,
|
||||||
InspectionFindingCommunicationsController,
|
InspectionFindingCommunicationsController,
|
||||||
InspectionEvidenceContentController,
|
InspectionEvidenceContentController,
|
||||||
@@ -31,6 +35,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
|||||||
F3FindingCatalogResolverService,
|
F3FindingCatalogResolverService,
|
||||||
FindingCatalogMergeService,
|
FindingCatalogMergeService,
|
||||||
InspectionFindingsService,
|
InspectionFindingsService,
|
||||||
|
InspectionFindingWorklistService,
|
||||||
|
FindingRecurrenceService,
|
||||||
InspectionEvidenceService,
|
InspectionEvidenceService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
@@ -38,6 +44,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
|||||||
F3FindingCatalogResolverService,
|
F3FindingCatalogResolverService,
|
||||||
FindingCatalogMergeService,
|
FindingCatalogMergeService,
|
||||||
InspectionFindingsService,
|
InspectionFindingsService,
|
||||||
|
InspectionFindingWorklistService,
|
||||||
|
FindingRecurrenceService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class InspectionFindingsModule {}
|
export class InspectionFindingsModule {}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ interface FindingDocumentView {
|
|||||||
actStatus: InspectionActStatus;
|
actStatus: InspectionActStatus;
|
||||||
visitId: string;
|
visitId: string;
|
||||||
visitCode: string;
|
visitCode: string;
|
||||||
visitTitle: string;
|
|
||||||
visitStatus: InspectionVisitStatus;
|
visitStatus: InspectionVisitStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +98,6 @@ export interface InspectionFindingListItem {
|
|||||||
verificationVisit: {
|
verificationVisit: {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
|
||||||
status: InspectionVisitStatus;
|
status: InspectionVisitStatus;
|
||||||
plannedStartAt: Date | null;
|
plannedStartAt: Date | null;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -143,7 +141,6 @@ export interface FindingVerificationHistoryEvent {
|
|||||||
verificationVisit: {
|
verificationVisit: {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
|
||||||
status: InspectionVisitStatus;
|
status: InspectionVisitStatus;
|
||||||
plannedStartAt: Date | null;
|
plannedStartAt: Date | null;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -811,13 +808,11 @@ export class InspectionFindingsService {
|
|||||||
'actStatus', act.status,
|
'actStatus', act.status,
|
||||||
'visitId', visit.id,
|
'visitId', visit.id,
|
||||||
'visitCode', visit.code,
|
'visitCode', visit.code,
|
||||||
'visitTitle', visit.title,
|
|
||||||
'visitStatus', visit.status
|
'visitStatus', visit.status
|
||||||
) AS document,
|
) AS document,
|
||||||
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
'id', verification_visit.id,
|
'id', verification_visit.id,
|
||||||
'code', verification_visit.code,
|
'code', verification_visit.code,
|
||||||
'title', verification_visit.title,
|
|
||||||
'status', verification_visit.status,
|
'status', verification_visit.status,
|
||||||
'plannedStartAt', verification_visit.planned_start_at
|
'plannedStartAt', verification_visit.planned_start_at
|
||||||
) END AS "verificationVisit",
|
) END AS "verificationVisit",
|
||||||
@@ -845,7 +840,7 @@ export class InspectionFindingsService {
|
|||||||
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
||||||
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT verification_target.id, verification_target.code, verification_target.title,
|
SELECT verification_target.id, verification_target.code,
|
||||||
verification_target.status, verification_target.planned_start_at
|
verification_target.status, verification_target.planned_start_at
|
||||||
FROM inspection_finding_verification_visits verification_link
|
FROM inspection_finding_verification_visits verification_link
|
||||||
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
|
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
|
||||||
@@ -923,7 +918,6 @@ export class InspectionFindingsService {
|
|||||||
CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
'id', visit.id,
|
'id', visit.id,
|
||||||
'code', visit.code,
|
'code', visit.code,
|
||||||
'title', visit.title,
|
|
||||||
'status', visit.status,
|
'status', visit.status,
|
||||||
'plannedStartAt', visit.planned_start_at
|
'plannedStartAt', visit.planned_start_at
|
||||||
) END AS "verificationVisit",
|
) END AS "verificationVisit",
|
||||||
|
|||||||
@@ -1,17 +1,128 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Req } from '@nestjs/common';
|
import { Body, Controller, ForbiddenException, Get, Param, ParseUUIDPipe, Patch, Post, Put, Req } from '@nestjs/common';
|
||||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import { AuditAction } from '../database/entities';
|
||||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||||
import type { DeliveryRow } from './inspection-document-delivery.service';
|
import type { DeliveryRow } from './inspection-document-delivery.service';
|
||||||
|
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||||
import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
|
import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
|
||||||
|
import { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
|
||||||
|
|
||||||
@Controller('document-delivery')
|
@Controller('document-delivery')
|
||||||
export class DocumentDeliveryController {
|
export class DocumentDeliveryController {
|
||||||
constructor(private readonly delivery:InspectionDocumentDeliveryService){}
|
constructor(
|
||||||
@Get('settings') @RequirePermissions('document_delivery.read') settings(){return this.delivery.settings();}
|
private readonly delivery: InspectionDocumentDeliveryService,
|
||||||
@Patch('settings') @RequirePermissions('document_delivery.manage') update(@Body() dto:UpdateDocumentDeliverySettingsDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.updateSettings(dto,principal,request);}
|
private readonly smtp: SmtpDeliveryService,
|
||||||
@Get('outbox') @RequirePermissions('document_delivery.read') outbox(){return this.delivery.list();}
|
private readonly audit: AuditService,
|
||||||
@Post('outbox/:id/retry') @RequirePermissions('document_delivery.manage') retry(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext):Promise<DeliveryRow>{return this.delivery.retry(id,principal,request);}
|
) {}
|
||||||
@Post('outbox/retry-pending') @RequirePermissions('document_delivery.manage') retryPending(@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.retryPending(principal,request);}
|
|
||||||
|
@Get('settings')
|
||||||
|
@RequirePermissions('document_delivery.read')
|
||||||
|
settings() {
|
||||||
|
return this.delivery.settings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('settings')
|
||||||
|
@RequirePermissions('document_delivery.manage')
|
||||||
|
update(
|
||||||
|
@Body() dto: UpdateDocumentDeliverySettingsDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.delivery.updateSettings(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('smtp')
|
||||||
|
@RequirePermissions('document_delivery.manage')
|
||||||
|
smtpSettings(@CurrentAuth() principal: AuthPrincipal) {
|
||||||
|
this.assertSystemAdmin(principal);
|
||||||
|
return this.smtp.publicSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('smtp')
|
||||||
|
@RequirePermissions('document_delivery.manage')
|
||||||
|
async updateSmtp(
|
||||||
|
@Body() dto: UpdateSmtpSettingsDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
this.assertSystemAdmin(principal);
|
||||||
|
const before = await this.smtp.publicSettings();
|
||||||
|
const after = await this.smtp.saveSettings(dto, principal.userId);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.SMTP_SETTINGS_UPDATED,
|
||||||
|
entityType: 'system_smtp_settings',
|
||||||
|
entityId: 'singleton',
|
||||||
|
beforeData: before as Record<string, unknown>,
|
||||||
|
afterData: after as Record<string, unknown>,
|
||||||
|
metadata: { passwordNeverReturned: true, restrictedToRole: 'admin' },
|
||||||
|
});
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('smtp/test')
|
||||||
|
@RequirePermissions('document_delivery.manage')
|
||||||
|
async testSmtp(
|
||||||
|
@Body() dto: TestSmtpSettingsDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
this.assertSystemAdmin(principal);
|
||||||
|
const sent = await this.smtp.send({
|
||||||
|
to: dto.email,
|
||||||
|
subject: 'DH Inspección · Prueba SMTP',
|
||||||
|
text: 'Este correo confirma que la configuración SMTP de DH Inspección funciona correctamente.',
|
||||||
|
attachment: {
|
||||||
|
filename: 'dh-inspeccion-smtp-test.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
content: Buffer.from('DH Inspección · Prueba SMTP OK\n', 'utf8'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.SMTP_TEST_SENT,
|
||||||
|
entityType: 'system_smtp_settings',
|
||||||
|
entityId: 'singleton',
|
||||||
|
afterData: { recipient: dto.email, messageId: sent.messageId },
|
||||||
|
metadata: { restrictedToRole: 'admin' },
|
||||||
|
});
|
||||||
|
return { ok: true, recipient: dto.email, messageId: sent.messageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('outbox')
|
||||||
|
@RequirePermissions('document_delivery.read')
|
||||||
|
outbox() {
|
||||||
|
return this.delivery.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('outbox/:id/retry')
|
||||||
|
@RequirePermissions('document_delivery.manage')
|
||||||
|
retry(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
): Promise<DeliveryRow> {
|
||||||
|
return this.delivery.retry(id, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('outbox/retry-pending')
|
||||||
|
@RequirePermissions('document_delivery.manage')
|
||||||
|
retryPending(
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.delivery.retryPending(principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSystemAdmin(principal: AuthPrincipal): void {
|
||||||
|
if (principal.roles.includes('admin')) return;
|
||||||
|
throw new ForbiddenException({
|
||||||
|
code: 'SMTP_SUPERADMIN_REQUIRED',
|
||||||
|
message: 'La configuración SMTP está reservada al Superadmin del sistema',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsEnum, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { InspectionReportFollowUpType } from '../../database/entities';
|
||||||
|
|
||||||
|
export class CreateInspectionReportFollowUpDto {
|
||||||
|
@IsEnum(InspectionReportFollowUpType)
|
||||||
|
type!: InspectionReportFollowUpType;
|
||||||
|
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
occurredAt!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
externalReference?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20000)
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsISO8601, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class OfficializeInspectionReportDto {
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(255)
|
||||||
|
gedoIfIdentifier!: string;
|
||||||
|
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
gedoOfficializedAt!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { InspectionDeadlineDayType } from '../../database/entities';
|
||||||
|
|
||||||
|
export class UpdateDeadlinePolicyDto {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(365)
|
||||||
|
urgentDays!: number;
|
||||||
|
|
||||||
|
@IsEnum(InspectionDeadlineDayType)
|
||||||
|
urgentDayType!: InspectionDeadlineDayType;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(365)
|
||||||
|
nonUrgentDays!: number;
|
||||||
|
|
||||||
|
@IsEnum(InspectionDeadlineDayType)
|
||||||
|
nonUrgentDayType!: InspectionDeadlineDayType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpsertBusinessCalendarDayDto {
|
||||||
|
@IsDateString()
|
||||||
|
date!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => value === true || value === 'true')
|
||||||
|
@IsBoolean()
|
||||||
|
isBusinessDay!: boolean;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
label!: string;
|
||||||
|
}
|
||||||
@@ -1,2 +1,12 @@
|
|||||||
import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator';
|
import { Transform } from 'class-transformer';
|
||||||
export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; }
|
import { IsEmail, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateDocumentDeliverySettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
|
||||||
|
)
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(320)
|
||||||
|
officeEmail?: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateInspectionReportDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20000)
|
||||||
|
executiveSummary?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40000)
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { SmtpSecurityMode } from '../../database/entities';
|
||||||
|
|
||||||
|
export class UpdateSmtpSettingsDto {
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
host!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(65535)
|
||||||
|
port!: number;
|
||||||
|
|
||||||
|
@IsEnum(SmtpSecurityMode)
|
||||||
|
securityMode!: SmtpSecurityMode;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
username?: string | null;
|
||||||
|
|
||||||
|
/** Omitir para conservar la clave actual; enviar null/cadena vacía para quitarla. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
|
password?: string | null;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(200)
|
||||||
|
fromName!: string;
|
||||||
|
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(320)
|
||||||
|
fromEmail!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null)
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(320)
|
||||||
|
replyTo?: string | null;
|
||||||
|
|
||||||
|
@Transform(({ value }) => value === true || value === 'true')
|
||||||
|
@IsBoolean()
|
||||||
|
enabled!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestSmtpSettingsDto {
|
||||||
|
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(320)
|
||||||
|
email!: string;
|
||||||
|
}
|
||||||
@@ -1,88 +1,212 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
function asArray(value: unknown): Array<Record<string, unknown>> { return Array.isArray(value) ? value.map(asRecord) : []; }
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
function text(value: unknown, fallback='-'): string { const out=String(value ?? '').trim(); return out || fallback; }
|
? value as Record<string, unknown>
|
||||||
function date(value: unknown): string { const d=new Date(String(value ?? '')); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('es-AR') : '-'; }
|
: {};
|
||||||
function clean(value: string): string { return value.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[–—]/g,'-').replace(/[“”]/g,'"').replace(/[‘’]/g,"'").replace(/[^\x20-\xFF]/g,'?'); }
|
}
|
||||||
function escapePdf(value: string): string { return clean(value).replaceAll('\\','\\\\').replaceAll('(','\\(').replaceAll(')','\\)'); }
|
|
||||||
function wrap(value: string, max=92): string[] { const words=clean(value).split(/\s+/).filter(Boolean); const out:string[]=[]; let line=''; for(const word of words){ const next=line?`${line} ${word}`:word; if(next.length>max&&line){out.push(line);line=word;}else line=next;} if(line)out.push(line); return out.length?out:['-']; }
|
function asArray(value: unknown): Array<Record<string, unknown>> {
|
||||||
|
return Array.isArray(value) ? value.map(asRecord) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown, fallback = '-'): string {
|
||||||
|
const out = String(value ?? '').trim();
|
||||||
|
return out || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function date(value: unknown): string {
|
||||||
|
const parsed = new Date(String(value ?? ''));
|
||||||
|
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[–—]/g, '-')
|
||||||
|
.replace(/[“”]/g, '"')
|
||||||
|
.replace(/[‘’]/g, "'")
|
||||||
|
.replace(/[^\x20-\xFF]/g, '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapePdf(value: string): string {
|
||||||
|
return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(value: string, max = 92): string[] {
|
||||||
|
const words = clean(value).split(/\s+/).filter(Boolean);
|
||||||
|
const out: string[] = [];
|
||||||
|
let line = '';
|
||||||
|
for (const word of words) {
|
||||||
|
const next = line ? `${line} ${word}` : word;
|
||||||
|
if (next.length > max && line) {
|
||||||
|
out.push(line);
|
||||||
|
line = word;
|
||||||
|
} else {
|
||||||
|
line = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line) out.push(line);
|
||||||
|
return out.length ? out : ['-'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockedSnapshot(snapshot: Record<string, unknown>): {
|
||||||
|
locked: Record<string, unknown>;
|
||||||
|
signatures: Array<Record<string, unknown>>;
|
||||||
|
finalSha256: unknown;
|
||||||
|
} {
|
||||||
|
const sealed = asRecord(snapshot);
|
||||||
|
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
|
||||||
|
return {
|
||||||
|
locked,
|
||||||
|
signatures: asArray(sealed.signatures),
|
||||||
|
finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyLabel(value: unknown): string {
|
||||||
|
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayTypeLabel(value: unknown): string {
|
||||||
|
return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles';
|
||||||
|
}
|
||||||
|
|
||||||
function lines(snapshot: Record<string, unknown>): string[] {
|
function lines(snapshot: Record<string, unknown>): string[] {
|
||||||
const prepared=asRecord(asRecord(snapshot).preparedSnapshot);
|
const source = lockedSnapshot(snapshot);
|
||||||
const act=asRecord(prepared.act);
|
const locked = source.locked;
|
||||||
const visit=asRecord(act.visit);
|
const act = asRecord(locked.act);
|
||||||
const responsible=asRecord(prepared.responsible);
|
const inspection = asRecord(act.inspection ?? asRecord(act).visit);
|
||||||
const team=asArray(prepared.team);
|
const responsible = asRecord(locked.responsible);
|
||||||
const assets=asArray(prepared.assets);
|
const inventories = asArray(locked.inventories ?? locked.assets);
|
||||||
const findings=asArray(prepared.findings);
|
const findings = asArray(locked.findings);
|
||||||
const signatures=asArray(snapshot.signatures);
|
const signatures = source.signatures;
|
||||||
const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE');
|
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 inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
|
||||||
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 deadlineText = act.deadlineAt
|
||||||
const out:string[]=[
|
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})`
|
||||||
|
: act.deadlineBasis === 'GEDO_DATE'
|
||||||
|
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO`
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
const out: string[] = [
|
||||||
'ACTA DE INSPECCION',
|
'ACTA DE INSPECCION',
|
||||||
'Documento automatico - diseno institucional pendiente de definicion',
|
|
||||||
'',
|
'',
|
||||||
`Acta: ${text(act.code)}`,
|
`Acta: ${text(act.code)}`,
|
||||||
`Inspeccion: ${text(visit.code)}`,
|
`Inspeccion: ${text(inspection.code)}`,
|
||||||
`Fecha: ${date(act.occurredAt)}`,
|
`Fecha: ${date(act.occurredAt)}`,
|
||||||
`Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`,
|
`Urgencia: ${urgencyLabel(act.urgency)}`,
|
||||||
`Area: ${areas.join(' / ') || 'Segun alcance del acta'}`,
|
`Plazo: ${deadlineText}`,
|
||||||
`Inspector/es: ${inspectors.join(' / ') || '-'}`,
|
|
||||||
`Responsable empresa: ${text(responsible.fullName)}`,
|
`Responsable empresa: ${text(responsible.fullName)}`,
|
||||||
`Cargo: ${text(responsible.position)}`,
|
`Cargo: ${text(responsible.position)}`,
|
||||||
'',
|
'',
|
||||||
'OBJETIVO', ...wrap(text(visit.objective)), '',
|
'RESUMEN',
|
||||||
'RESUMEN', ...wrap(text(act.summary)), '',
|
...wrap(text(act.summary)),
|
||||||
'OBSERVACIONES', ...wrap(text(act.observations)), '',
|
'',
|
||||||
'ELEMENTOS INSPECCIONADOS',
|
'OBSERVACIONES',
|
||||||
|
...wrap(text(act.observations)),
|
||||||
|
'',
|
||||||
|
'INVENTARIO INSPECCIONADO',
|
||||||
];
|
];
|
||||||
if(!assets.length) out.push('-');
|
|
||||||
for(const item of assets) out.push(...wrap(`${text(item.code)} | ${text(item.name)}${text(item.commonName,'') ? ` | ${text(item.commonName,'')}` : ''} | ${text(item.typeName)}`));
|
if (!inventories.length) out.push('-');
|
||||||
out.push('', 'HALLAZGOS');
|
for (const item of inventories) {
|
||||||
if(!findings.length) out.push('Sin hallazgos registrados.');
|
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
|
||||||
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('', 'HALLAZGOS');
|
||||||
|
if (!findings.length) out.push('Sin hallazgos registrados.');
|
||||||
|
for (const item of findings) {
|
||||||
|
const recurrence = item.isRecurrence
|
||||||
|
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}`
|
||||||
|
: '';
|
||||||
|
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`));
|
||||||
|
out.push(...wrap(`Descripcion: ${text(item.description)}`));
|
||||||
|
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push('', 'FIRMAS Y CONSTANCIAS');
|
||||||
|
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.');
|
||||||
|
for (const signature of inspectorSignatures) {
|
||||||
|
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`));
|
||||||
|
}
|
||||||
|
if (!companySignature) {
|
||||||
|
out.push('Manifestacion de empresa: pendiente.');
|
||||||
|
} else if (text(companySignature.status, '') === 'SIGNED') {
|
||||||
|
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
|
||||||
|
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad');
|
||||||
|
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
|
||||||
|
} else {
|
||||||
|
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(
|
||||||
|
'',
|
||||||
|
'INTEGRIDAD',
|
||||||
|
`Hash del Acta sellada: ${text(source.finalSha256)}`,
|
||||||
|
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`,
|
||||||
|
);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function objectBuffer(id: number, body: Buffer|string): Buffer { const data=Buffer.isBuffer(body)?body:Buffer.from(body,'latin1'); return Buffer.concat([Buffer.from(`${id} 0 obj\n`,'ascii'),data,Buffer.from('\nendobj\n','ascii')]); }
|
function objectBuffer(id: number, body: Buffer | string): Buffer {
|
||||||
|
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from(`${id} 0 obj\n`, 'ascii'),
|
||||||
|
data,
|
||||||
|
Buffer.from('\nendobj\n', 'ascii'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
|
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
|
||||||
const all=lines(snapshot);
|
const all = lines(snapshot);
|
||||||
const chunks:Array<string[]>=[];
|
const chunks: Array<string[]> = [];
|
||||||
for(let i=0;i<all.length;i+=56) chunks.push(all.slice(i,i+56));
|
for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
|
||||||
if(!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
||||||
const pageCount=chunks.length;
|
|
||||||
const pageIds=Array.from({length:pageCount},(_,i)=>4+i*2);
|
const pageCount = chunks.length;
|
||||||
const contentIds=Array.from({length:pageCount},(_,i)=>5+i*2);
|
const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
|
||||||
const objects:Buffer[]=[];
|
const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
|
||||||
objects.push(objectBuffer(1,'<< /Type /Catalog /Pages 2 0 R >>'));
|
const objects: Buffer[] = [];
|
||||||
objects.push(objectBuffer(2,`<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map(id=>`${id} 0 R`).join(' ')}] >>`));
|
objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
|
||||||
objects.push(objectBuffer(3,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
|
||||||
chunks.forEach((chunk,index)=>{
|
objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
||||||
const content=chunk.map((line,i)=>`${i===0?'':'T* '}(${escapePdf(line)}) Tj`).join('\n');
|
|
||||||
const stream=Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`,'latin1');
|
chunks.forEach((chunk, index) => {
|
||||||
objects.push(objectBuffer(pageIds[index]!,`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`));
|
const content = chunk
|
||||||
objects.push(objectBuffer(contentIds[index]!,Buffer.concat([Buffer.from(`<< /Length ${stream.length} >>\nstream\n`,'ascii'),stream,Buffer.from('\nendstream','ascii')])));
|
.map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`)
|
||||||
|
.join('\n');
|
||||||
|
const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1');
|
||||||
|
objects.push(objectBuffer(
|
||||||
|
pageIds[index]!,
|
||||||
|
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
|
||||||
|
));
|
||||||
|
objects.push(objectBuffer(
|
||||||
|
contentIds[index]!,
|
||||||
|
Buffer.concat([
|
||||||
|
Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'),
|
||||||
|
stream,
|
||||||
|
Buffer.from('\nendstream', 'ascii'),
|
||||||
|
]),
|
||||||
|
));
|
||||||
});
|
});
|
||||||
const header=Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n','binary');
|
|
||||||
const offsets:number[]=[0]; let pos=header.length;
|
const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
|
||||||
for(const obj of objects){ offsets.push(pos); pos+=obj.length; }
|
const offsets: number[] = [0];
|
||||||
const xrefOffset=pos;
|
let position = header.length;
|
||||||
const xref=[`xref\n0 ${objects.length+1}\n`,`0000000000 65535 f \n`,...objects.map((_,i)=>`${String(offsets[i+1]).padStart(10,'0')} 00000 n \n`)].join('');
|
for (const object of objects) {
|
||||||
const trailer=`trailer\n<< /Size ${objects.length+1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
offsets.push(position);
|
||||||
const buffer=Buffer.concat([header,...objects,Buffer.from(xref+trailer,'ascii')]);
|
position += object.length;
|
||||||
return { buffer, sha256:createHash('sha256').update(buffer).digest('hex') };
|
}
|
||||||
|
const xrefOffset = position;
|
||||||
|
const xref = [
|
||||||
|
`xref\n0 ${objects.length + 1}\n`,
|
||||||
|
'0000000000 65535 f \n',
|
||||||
|
...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`),
|
||||||
|
].join('');
|
||||||
|
const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
|
||||||
|
const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]);
|
||||||
|
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,20 +8,112 @@ import { buildInspectionActPdf } from './inspection-act-pdf-builder';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InspectionActPdfService {
|
export class InspectionActPdfService {
|
||||||
private readonly root:string;
|
private readonly root: string;
|
||||||
constructor(private readonly dataSource:DataSource, config:ConfigService){ const configured=config.get<string>('INSPECTION_ACT_PDF_ROOT')??'/app/storage/asset-media/inspection-acts-pdf'; if(!isAbsolute(configured))throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); this.root=resolve(configured); if(this.root===parse(this.root).root)throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); }
|
|
||||||
|
|
||||||
async ensure(actId:string):Promise<void>{
|
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
||||||
const [row]=await this.dataSource.query(`SELECT a.id,a.code,a.closure_sha256 AS "closureSha256",c.final_snapshot AS "finalSnapshot",p.status,p.stored_name AS "storedName" FROM inspection_acts a JOIN inspection_act_closures c ON c.act_id=a.id LEFT JOIN inspection_act_pdf_artifacts p ON p.act_id=a.id WHERE a.id=$1 AND a.status='CLOSED'`,[actId]) as Array<{id:string;code:string;closureSha256:string;finalSnapshot:Record<string,unknown>;status:string|null;storedName:string|null}>;
|
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
|
||||||
if(!row)throw new NotFoundException({code:'INSPECTION_ACT_NOT_CLOSED',message:'El acta cerrada no está disponible'});
|
?? '/app/storage/asset-media/inspection-acts-pdf';
|
||||||
if(row.status==='READY'&&row.storedName){ try{ await this.content(actId); return; }catch{} }
|
if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
|
||||||
await this.dataSource.query(`INSERT INTO inspection_act_pdf_artifacts (act_id,status) VALUES ($1,'PENDING') ON CONFLICT (act_id) DO UPDATE SET status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP`,[actId]);
|
this.root = resolve(configured);
|
||||||
try{
|
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
|
||||||
const snapshot={...row.finalSnapshot,finalSha256:row.closureSha256}; const built=buildInspectionActPdf(snapshot); await mkdir(this.root,{recursive:true,mode:0o700}); const storedName=`${actId}.pdf`; const originalName=`${row.code}.pdf`; const filePath=resolve(this.root,storedName); await writeFile(filePath,built.buffer,{mode:0o600});
|
|
||||||
await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,originalName,storedName,built.buffer.length,built.sha256]);
|
|
||||||
}catch(error){ const message=error instanceof Error?error.message.slice(0,500):'Error desconocido'; await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,message]).catch(()=>undefined); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async content(actId:string):Promise<{buffer:Buffer;originalName:string;mimeType:string}>{ const [row]=await this.dataSource.query(`SELECT original_name AS "originalName",stored_name AS "storedName",mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 FROM inspection_act_pdf_artifacts WHERE act_id=$1 AND status='READY'`,[actId]) as Array<{originalName:string;storedName:string;mimeType:string;sizeBytes:number;sha256:string}>; if(!row)throw new NotFoundException({code:'INSPECTION_ACT_PDF_NOT_READY',message:'El PDF del acta todavía no está disponible'}); const filePath=resolve(this.root,row.storedName); if(!filePath.startsWith(`${this.root}/`))throw this.storageError(); const st=await stat(filePath).catch(()=>null); if(!st?.isFile()||st.size!==row.sizeBytes)throw this.storageError(); const buffer=await readFile(filePath); if(createHash('sha256').update(buffer).digest('hex')!==row.sha256)throw this.storageError(); return {buffer,originalName:row.originalName,mimeType:row.mimeType}; }
|
async ensure(actId: string): Promise<void> {
|
||||||
private storageError(){return new InternalServerErrorException({code:'INSPECTION_ACT_PDF_STORAGE_ERROR',message:'El PDF del acta no está disponible o no supera la validación de integridad'});}
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
act.id,
|
||||||
|
act.code,
|
||||||
|
act.closure_sha256 AS "closureSha256",
|
||||||
|
closure.final_snapshot AS "finalSnapshot",
|
||||||
|
artifact.status,
|
||||||
|
artifact.stored_name AS "storedName"
|
||||||
|
FROM inspection_acts act
|
||||||
|
JOIN inspection_act_closures closure ON closure.act_id=act.id
|
||||||
|
LEFT JOIN inspection_act_pdf_artifacts artifact ON artifact.act_id=act.id
|
||||||
|
WHERE act.id=$1 AND act.status='SEALED'
|
||||||
|
`, [actId]) as Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
closureSha256: string;
|
||||||
|
finalSnapshot: Record<string, unknown>;
|
||||||
|
status: string | null;
|
||||||
|
storedName: string | null;
|
||||||
|
}>;
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_ACT_NOT_SEALED',
|
||||||
|
message: 'El PDF sólo puede generarse desde un Acta SELLADA',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (row.status === 'READY' && row.storedName) {
|
||||||
|
try {
|
||||||
|
await this.content(actId);
|
||||||
|
return;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
await this.dataSource.query(`
|
||||||
|
INSERT INTO inspection_act_pdf_artifacts(act_id,status)
|
||||||
|
VALUES($1,'PENDING')
|
||||||
|
ON CONFLICT (act_id) DO UPDATE SET
|
||||||
|
status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [actId]);
|
||||||
|
try {
|
||||||
|
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
|
||||||
|
const built = buildInspectionActPdf(snapshot);
|
||||||
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
|
const storedName = `${actId}.pdf`;
|
||||||
|
const originalName = `${row.code}.pdf`;
|
||||||
|
const filePath = resolve(this.root, storedName);
|
||||||
|
await writeFile(filePath, built.buffer, { mode: 0o600 });
|
||||||
|
await this.dataSource.query(`
|
||||||
|
UPDATE inspection_act_pdf_artifacts
|
||||||
|
SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',
|
||||||
|
size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE act_id=$1
|
||||||
|
`, [actId, originalName, storedName, built.buffer.length, built.sha256]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
|
||||||
|
await this.dataSource.query(`
|
||||||
|
UPDATE inspection_act_pdf_artifacts
|
||||||
|
SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE act_id=$1
|
||||||
|
`, [actId, message]).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT original_name AS "originalName",stored_name AS "storedName",
|
||||||
|
mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256
|
||||||
|
FROM inspection_act_pdf_artifacts
|
||||||
|
WHERE act_id=$1 AND status='READY'
|
||||||
|
`, [actId]) as Array<{
|
||||||
|
originalName: string;
|
||||||
|
storedName: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
sha256: string;
|
||||||
|
}>;
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_ACT_PDF_NOT_READY',
|
||||||
|
message: 'El PDF del Acta todavía no está disponible',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const filePath = resolve(this.root, row.storedName);
|
||||||
|
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
|
||||||
|
const fileStat = await stat(filePath).catch(() => null);
|
||||||
|
if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.storageError();
|
||||||
|
const buffer = await readFile(filePath);
|
||||||
|
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError();
|
||||||
|
return { buffer, originalName: row.originalName, mimeType: row.mimeType };
|
||||||
|
}
|
||||||
|
|
||||||
|
private storageError(): InternalServerErrorException {
|
||||||
|
return new InternalServerErrorException({
|
||||||
|
code: 'INSPECTION_ACT_PDF_STORAGE_ERROR',
|
||||||
|
message: 'El PDF del Acta no está disponible o no supera la validación de integridad',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Put, 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 { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto';
|
||||||
|
import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service';
|
||||||
|
|
||||||
|
@Controller('inspection-deadlines')
|
||||||
|
export class InspectionDeadlineAdminController {
|
||||||
|
constructor(private readonly deadlines: InspectionDeadlineAdminService) {}
|
||||||
|
|
||||||
|
@Get('policy')
|
||||||
|
@RequirePermissions('inspections.read')
|
||||||
|
policy() {
|
||||||
|
return this.deadlines.policy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('policy')
|
||||||
|
@RequirePermissions('inspections.manage')
|
||||||
|
updatePolicy(
|
||||||
|
@Body() dto: UpdateDeadlinePolicyDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.deadlines.updatePolicy(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('calendar')
|
||||||
|
@RequirePermissions('inspections.read')
|
||||||
|
calendar() {
|
||||||
|
return this.deadlines.calendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('calendar')
|
||||||
|
@RequirePermissions('inspections.manage')
|
||||||
|
upsertDay(
|
||||||
|
@Body() dto: UpsertBusinessCalendarDayDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.deadlines.upsertCalendarDay(dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('calendar/:date')
|
||||||
|
@RequirePermissions('inspections.manage')
|
||||||
|
removeDay(
|
||||||
|
@Param('date') date: string,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.deadlines.removeCalendarDay(date, principal, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
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 } from '../database/entities';
|
||||||
|
import type { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InspectionDeadlineAdminService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async policy() {
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT id,
|
||||||
|
urgent_days AS "urgentDays",
|
||||||
|
urgent_day_type AS "urgentDayType",
|
||||||
|
non_urgent_days AS "nonUrgentDays",
|
||||||
|
non_urgent_day_type AS "nonUrgentDayType",
|
||||||
|
updated_by AS "updatedBy",
|
||||||
|
updated_at AS "updatedAt"
|
||||||
|
FROM inspection_deadline_policies
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT 1
|
||||||
|
`);
|
||||||
|
return row ?? {
|
||||||
|
urgentDays: 5,
|
||||||
|
urgentDayType: 'BUSINESS',
|
||||||
|
nonUrgentDays: 10,
|
||||||
|
nonUrgentDayType: 'BUSINESS',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePolicy(
|
||||||
|
dto: UpdateDeadlinePolicyDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const before = await this.policy();
|
||||||
|
const [existing] = await this.dataSource.query(`
|
||||||
|
SELECT id FROM inspection_deadline_policies ORDER BY created_at LIMIT 1
|
||||||
|
`) as Array<{ id: string }>;
|
||||||
|
if (existing) {
|
||||||
|
await this.dataSource.query(`
|
||||||
|
UPDATE inspection_deadline_policies
|
||||||
|
SET urgent_days=$2,
|
||||||
|
urgent_day_type=$3,
|
||||||
|
non_urgent_days=$4,
|
||||||
|
non_urgent_day_type=$5,
|
||||||
|
updated_by=$6,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
`, [
|
||||||
|
existing.id,
|
||||||
|
dto.urgentDays,
|
||||||
|
dto.urgentDayType,
|
||||||
|
dto.nonUrgentDays,
|
||||||
|
dto.nonUrgentDayType,
|
||||||
|
principal.userId,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
await this.dataSource.query(`
|
||||||
|
INSERT INTO inspection_deadline_policies(
|
||||||
|
urgent_days,urgent_day_type,non_urgent_days,non_urgent_day_type,updated_by
|
||||||
|
) VALUES($1,$2,$3,$4,$5)
|
||||||
|
`, [
|
||||||
|
dto.urgentDays,
|
||||||
|
dto.urgentDayType,
|
||||||
|
dto.nonUrgentDays,
|
||||||
|
dto.nonUrgentDayType,
|
||||||
|
principal.userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const after = await this.policy();
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_DEADLINE_POLICY_UPDATED,
|
||||||
|
entityType: 'inspection_deadline_policy',
|
||||||
|
entityId: String((after as { id?: string }).id ?? 'singleton'),
|
||||||
|
beforeData: before as Record<string, unknown>,
|
||||||
|
afterData: after as Record<string, unknown>,
|
||||||
|
metadata: {
|
||||||
|
appliesOnlyToFutureLockedActs: true,
|
||||||
|
historicalActsKeepSnapshot: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
async calendar() {
|
||||||
|
const data = await this.dataSource.query(`
|
||||||
|
SELECT id,date,is_business_day AS "isBusinessDay",label,
|
||||||
|
updated_by AS "updatedBy",created_at AS "createdAt",updated_at AS "updatedAt"
|
||||||
|
FROM inspection_business_calendar_days
|
||||||
|
ORDER BY date
|
||||||
|
`);
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertCalendarDay(
|
||||||
|
dto: UpsertBusinessCalendarDayDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const [before] = await this.dataSource.query(`
|
||||||
|
SELECT id,date,is_business_day AS "isBusinessDay",label
|
||||||
|
FROM inspection_business_calendar_days WHERE date=$1::date
|
||||||
|
`, [dto.date]);
|
||||||
|
await this.dataSource.query(`
|
||||||
|
INSERT INTO inspection_business_calendar_days(date,is_business_day,label,updated_by)
|
||||||
|
VALUES($1::date,$2,$3,$4)
|
||||||
|
ON CONFLICT(date) DO UPDATE SET
|
||||||
|
is_business_day=EXCLUDED.is_business_day,
|
||||||
|
label=EXCLUDED.label,
|
||||||
|
updated_by=EXCLUDED.updated_by,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
`, [dto.date, dto.isBusinessDay, dto.label, principal.userId]);
|
||||||
|
const [after] = await this.dataSource.query(`
|
||||||
|
SELECT id,date,is_business_day AS "isBusinessDay",label,
|
||||||
|
updated_by AS "updatedBy",updated_at AS "updatedAt"
|
||||||
|
FROM inspection_business_calendar_days WHERE date=$1::date
|
||||||
|
`, [dto.date]);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED,
|
||||||
|
entityType: 'inspection_business_calendar_day',
|
||||||
|
entityId: String(after.id),
|
||||||
|
beforeData: before ?? null,
|
||||||
|
afterData: after,
|
||||||
|
});
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCalendarDay(
|
||||||
|
date: string,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
const [before] = await this.dataSource.query(`
|
||||||
|
SELECT id,date,is_business_day AS "isBusinessDay",label
|
||||||
|
FROM inspection_business_calendar_days WHERE date=$1::date
|
||||||
|
`, [date]);
|
||||||
|
if (before) {
|
||||||
|
await this.dataSource.query(`DELETE FROM inspection_business_calendar_days WHERE id=$1`, [before.id]);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED,
|
||||||
|
entityType: 'inspection_business_calendar_day',
|
||||||
|
entityId: String(before.id),
|
||||||
|
beforeData: before,
|
||||||
|
afterData: null,
|
||||||
|
metadata: { removed: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { removed: Boolean(before), date };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ 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';
|
||||||
|
|
||||||
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR';
|
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR';
|
||||||
|
|
||||||
export interface DeliveryRow {
|
export interface DeliveryRow {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,21 +40,14 @@ export class InspectionDocumentDeliveryService {
|
|||||||
|
|
||||||
async settings() {
|
async settings() {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT office_email AS "officeEmail",updated_at AS "updatedAt"
|
||||||
office_email AS "officeEmail",
|
|
||||||
director_email AS "directorEmail",
|
|
||||||
updated_at AS "updatedAt"
|
|
||||||
FROM institutional_delivery_settings
|
FROM institutional_delivery_settings
|
||||||
WHERE id=1
|
WHERE id=1
|
||||||
`) as Array<{
|
`) as Array<{ officeEmail: string | null; updatedAt: Date }>;
|
||||||
officeEmail: string | null;
|
|
||||||
directorEmail: string | null;
|
|
||||||
updatedAt: Date;
|
|
||||||
}>;
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
smtpConfigured: this.smtp.configured(),
|
smtpConfigured: await this.smtp.configured(),
|
||||||
mailFrom: this.config.get<string>('MAIL_FROM') ?? null,
|
mailFrom: await this.smtp.fromAddress() ?? this.config.get<string>('MAIL_FROM') ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +59,9 @@ export class InspectionDocumentDeliveryService {
|
|||||||
const before = await this.settings();
|
const before = await this.settings();
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE institutional_delivery_settings
|
UPDATE institutional_delivery_settings
|
||||||
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
|
SET office_email=$1,updated_by=$2,updated_at=CURRENT_TIMESTAMP
|
||||||
WHERE id=1
|
WHERE id=1
|
||||||
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]);
|
`, [dto.officeEmail ?? null, principal.userId]);
|
||||||
const after = await this.settings();
|
const after = await this.settings();
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
...administrationAuditContext(principal, request),
|
...administrationAuditContext(principal, request),
|
||||||
@@ -84,33 +77,23 @@ export class InspectionDocumentDeliveryService {
|
|||||||
async list() {
|
async list() {
|
||||||
const data = await this.dataSource.query(`
|
const data = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT
|
||||||
d.id,
|
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||||
d.act_id AS "actId",
|
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||||
d.report_id AS "reportId",
|
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||||
d.document_kind AS "documentKind",
|
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||||
d.recipient_kind AS "recipientKind",
|
d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt",
|
||||||
d.recipient_asset_id AS "recipientAssetId",
|
d.provider_message_id AS "providerMessageId",d.last_error AS "lastError",
|
||||||
d.recipient_user_id AS "recipientUserId",
|
d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode",
|
||||||
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",
|
recipient.name AS "recipientAssetName",
|
||||||
CASE
|
CASE WHEN recipient_user.id IS NULL THEN NULL
|
||||||
WHEN recipient_user.id IS NULL THEN NULL
|
ELSE btrim(concat_ws(' ',recipient_user.first_name,recipient_user.last_name))
|
||||||
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
|
|
||||||
END AS "recipientUserName"
|
END AS "recipientUserName"
|
||||||
FROM inspection_document_deliveries d
|
FROM inspection_document_deliveries d
|
||||||
JOIN inspection_acts a ON a.id=d.act_id
|
JOIN inspection_acts a ON a.id=d.act_id
|
||||||
LEFT JOIN inspection_reports r ON r.id=d.report_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 assets recipient ON recipient.id=d.recipient_asset_id
|
||||||
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
|
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
|
||||||
|
WHERE d.recipient_kind<>'DIRECTOR'
|
||||||
ORDER BY d.created_at DESC
|
ORDER BY d.created_at DESC
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`);
|
`);
|
||||||
@@ -154,11 +137,9 @@ export class InspectionDocumentDeliveryService {
|
|||||||
|
|
||||||
async retryPending(principal: AuthPrincipal, request: RequestWithContext) {
|
async retryPending(principal: AuthPrincipal, request: RequestWithContext) {
|
||||||
const rows = await this.dataSource.query(`
|
const rows = await this.dataSource.query(`
|
||||||
SELECT id
|
SELECT id FROM inspection_document_deliveries
|
||||||
FROM inspection_document_deliveries
|
WHERE status<>'SENT' AND recipient_kind<>'DIRECTOR'
|
||||||
WHERE status<>'SENT'
|
ORDER BY created_at ASC LIMIT 100
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT 100
|
|
||||||
`) as Array<{ id: string }>;
|
`) as Array<{ id: string }>;
|
||||||
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined);
|
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined);
|
||||||
return { processed: rows.length };
|
return { processed: rows.length };
|
||||||
@@ -166,10 +147,9 @@ export class InspectionDocumentDeliveryService {
|
|||||||
|
|
||||||
private async ensureRows(actId: string, reportId: string | null) {
|
private async ensureRows(actId: string, reportId: string | null) {
|
||||||
const [settings] = await this.dataSource.query(`
|
const [settings] = await this.dataSource.query(`
|
||||||
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
SELECT office_email AS "officeEmail"
|
||||||
FROM institutional_delivery_settings
|
FROM institutional_delivery_settings WHERE id=1
|
||||||
WHERE id=1
|
`) as Array<{ officeEmail: string | null }>;
|
||||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
|
||||||
|
|
||||||
const companies = await this.dataSource.query(`
|
const companies = await this.dataSource.query(`
|
||||||
SELECT DISTINCT company.id,profile.notification_email AS email
|
SELECT DISTINCT company.id,profile.notification_email AS email
|
||||||
@@ -185,9 +165,7 @@ export class InspectionDocumentDeliveryService {
|
|||||||
`, [actId]) as Array<{ id: string; email: string | null }>;
|
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||||
|
|
||||||
const [inspector] = await this.dataSource.query(`
|
const [inspector] = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT inspector.id,inspector.email
|
||||||
inspector.id,
|
|
||||||
inspector.email
|
|
||||||
FROM inspection_acts act
|
FROM inspection_acts act
|
||||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||||
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
|
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
|
||||||
@@ -196,52 +174,32 @@ export class InspectionDocumentDeliveryService {
|
|||||||
|
|
||||||
for (const company of companies) {
|
for (const company of companies) {
|
||||||
await this.upsertRow({
|
await this.upsertRow({
|
||||||
actId,
|
actId,reportId,documentKind:'ACT_PDF',recipientKind:'COMPANY',
|
||||||
reportId,
|
recipientAssetId:company.id,recipientUserId:null,recipientKey:company.id,
|
||||||
documentKind: 'ACT_PDF',
|
recipientEmail:company.email,
|
||||||
recipientKind: 'COMPANY',
|
|
||||||
recipientAssetId: company.id,
|
|
||||||
recipientUserId: null,
|
|
||||||
recipientKey: company.id,
|
|
||||||
recipientEmail: company.email,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.upsertRow({
|
await this.upsertRow({
|
||||||
actId,
|
actId,reportId,documentKind:'ACT_PDF',recipientKind:'OFFICE',
|
||||||
reportId,
|
recipientAssetId:null,recipientUserId:null,
|
||||||
documentKind: 'ACT_PDF',
|
recipientKey:'00000000-0000-0000-0000-000000000000',
|
||||||
recipientKind: 'OFFICE',
|
recipientEmail:settings?.officeEmail ?? null,
|
||||||
recipientAssetId: null,
|
|
||||||
recipientUserId: null,
|
|
||||||
recipientKey: '00000000-0000-0000-0000-000000000000',
|
|
||||||
recipientEmail: settings?.officeEmail ?? null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (inspector) {
|
if (inspector) {
|
||||||
await this.upsertRow({
|
await this.upsertRow({
|
||||||
actId,
|
actId,reportId,documentKind:'ACT_PDF',recipientKind:'INSPECTOR',
|
||||||
reportId,
|
recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
|
||||||
documentKind: 'ACT_PDF',
|
recipientEmail:inspector.email,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
if (reportId) {
|
||||||
|
await this.upsertRow({
|
||||||
|
actId,reportId,documentKind:'REPORT_WORD',recipientKind:'INSPECTOR',
|
||||||
|
recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
|
||||||
|
recipientEmail:inspector.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,31 +225,21 @@ export class InspectionDocumentDeliveryService {
|
|||||||
recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id),
|
recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id),
|
||||||
recipient_email=CASE
|
recipient_email=CASE
|
||||||
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
|
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
|
||||||
ELSE EXCLUDED.recipient_email
|
ELSE EXCLUDED.recipient_email END,
|
||||||
END,
|
|
||||||
status=CASE
|
status=CASE
|
||||||
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
|
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
|
||||||
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
|
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
|
||||||
ELSE inspection_document_deliveries.status
|
ELSE inspection_document_deliveries.status END,
|
||||||
END,
|
|
||||||
updated_at=CURRENT_TIMESTAMP
|
updated_at=CURRENT_TIMESTAMP
|
||||||
`, [
|
`, [
|
||||||
input.actId,
|
input.actId,input.reportId,input.documentKind,input.recipientKind,
|
||||||
input.reportId,
|
input.recipientAssetId,input.recipientUserId,input.recipientKey,input.recipientEmail,initialStatus,
|
||||||
input.documentKind,
|
|
||||||
input.recipientKind,
|
|
||||||
input.recipientAssetId,
|
|
||||||
input.recipientUserId,
|
|
||||||
input.recipientKey,
|
|
||||||
input.recipientEmail,
|
|
||||||
initialStatus,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
|
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
|
||||||
return this.dataSource.query(`
|
return this.dataSource.query(`
|
||||||
SELECT
|
SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
|
||||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||||
@@ -299,15 +247,14 @@ export class InspectionDocumentDeliveryService {
|
|||||||
FROM inspection_document_deliveries d
|
FROM inspection_document_deliveries d
|
||||||
JOIN inspection_acts a ON a.id=d.act_id
|
JOIN inspection_acts a ON a.id=d.act_id
|
||||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||||
WHERE d.act_id=$1
|
WHERE d.act_id=$1 AND d.recipient_kind<>'DIRECTOR'
|
||||||
ORDER BY d.created_at
|
ORDER BY d.created_at
|
||||||
`, [actId]) as Promise<DeliveryRow[]>;
|
`, [actId]) as Promise<DeliveryRow[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load(id: string): Promise<DeliveryRow> {
|
private async load(id: string): Promise<DeliveryRow> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT
|
SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
|
||||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||||
@@ -315,12 +262,11 @@ export class InspectionDocumentDeliveryService {
|
|||||||
FROM inspection_document_deliveries d
|
FROM inspection_document_deliveries d
|
||||||
JOIN inspection_acts a ON a.id=d.act_id
|
JOIN inspection_acts a ON a.id=d.act_id
|
||||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||||
WHERE d.id=$1
|
WHERE d.id=$1 AND d.recipient_kind<>'DIRECTOR'
|
||||||
`, [id]) as DeliveryRow[];
|
`, [id]) as DeliveryRow[];
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException({
|
throw new NotFoundException({
|
||||||
code: 'DOCUMENT_DELIVERY_NOT_FOUND',
|
code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada',
|
||||||
message: 'Entrega documental no encontrada',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
@@ -330,47 +276,37 @@ export class InspectionDocumentDeliveryService {
|
|||||||
let email: string | null = null;
|
let email: string | null = null;
|
||||||
if (row.recipientKind === 'COMPANY' && row.recipientAssetId) {
|
if (row.recipientKind === 'COMPANY' && row.recipientAssetId) {
|
||||||
const [company] = await this.dataSource.query(`
|
const [company] = await this.dataSource.query(`
|
||||||
SELECT notification_email AS email
|
SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1
|
||||||
FROM organization_profiles
|
|
||||||
WHERE asset_id=$1
|
|
||||||
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
|
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
|
||||||
email = company?.email ?? null;
|
email = company?.email ?? null;
|
||||||
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
||||||
const [inspector] = await this.dataSource.query(`
|
const [inspector] = await this.dataSource.query(`
|
||||||
SELECT email
|
SELECT email FROM users WHERE id=$1 AND is_active=true
|
||||||
FROM users
|
|
||||||
WHERE id=$1 AND is_active=true
|
|
||||||
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
||||||
email = inspector?.email ?? null;
|
email = inspector?.email ?? null;
|
||||||
} else {
|
} else {
|
||||||
const [settings] = await this.dataSource.query(`
|
const [settings] = await this.dataSource.query(`
|
||||||
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
SELECT office_email AS "officeEmail" FROM institutional_delivery_settings WHERE id=1
|
||||||
FROM institutional_delivery_settings
|
`) as Array<{ officeEmail: string | null }>;
|
||||||
WHERE id=1
|
email = settings?.officeEmail ?? null;
|
||||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
|
||||||
email = row.recipientKind === 'OFFICE'
|
|
||||||
? settings?.officeEmail ?? null
|
|
||||||
: settings?.directorEmail ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_document_deliveries
|
UPDATE inspection_document_deliveries
|
||||||
SET recipient_email=$2,
|
SET recipient_email=$2,
|
||||||
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
||||||
last_error=NULL,
|
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||||
updated_at=CURRENT_TIMESTAMP
|
|
||||||
WHERE id=$1 AND status<>'SENT'
|
WHERE id=$1 AND status<>'SENT'
|
||||||
`, [row.id, email]);
|
`, [row.id,email]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async attempt(row: DeliveryRow) {
|
private async attempt(row: DeliveryRow) {
|
||||||
if (row.status === 'SENT') return;
|
if (row.status === 'SENT') return;
|
||||||
if (!row.recipientEmail) {
|
if (!row.recipientEmail) {
|
||||||
await this.setStatus(row.id, 'WAITING_RECIPIENT', 'Destinatario no configurado');
|
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.smtp.configured()) {
|
if (!await this.smtp.configured()) {
|
||||||
await this.setStatus(row.id, 'WAITING_TRANSPORT', 'SMTP no configurado');
|
await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,28 +315,18 @@ export class InspectionDocumentDeliveryService {
|
|||||||
if (row.documentKind === 'ACT_PDF') {
|
if (row.documentKind === 'ACT_PDF') {
|
||||||
await this.pdf.ensure(row.actId);
|
await this.pdf.ensure(row.actId);
|
||||||
const file = await this.pdf.content(row.actId);
|
const file = await this.pdf.content(row.actId);
|
||||||
attachment = {
|
attachment = { filename:file.originalName,mimeType:file.mimeType,content:file.buffer };
|
||||||
filename: file.originalName,
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
content: file.buffer,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
if (!row.reportId) throw new Error('Informe no vinculado');
|
if (!row.reportId) throw new Error('Informe no vinculado');
|
||||||
await this.word.ensure(row.reportId);
|
await this.word.ensure(row.reportId);
|
||||||
const file = await this.word.content(row.reportId);
|
const file = await this.word.content(row.reportId);
|
||||||
const { readFile } = await import('node:fs/promises');
|
const { readFile } = await import('node:fs/promises');
|
||||||
attachment = {
|
attachment = {
|
||||||
filename: file.originalName,
|
filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath),
|
||||||
mimeType: file.mimeType,
|
|
||||||
content: await readFile(file.filePath),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.setStatus(
|
await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error ? error.message : 'Documento no disponible');
|
||||||
row.id,
|
|
||||||
'WAITING_ARTIFACT',
|
|
||||||
error instanceof Error ? error.message : 'Documento no disponible',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,62 +335,41 @@ export class InspectionDocumentDeliveryService {
|
|||||||
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
|
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
|
||||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||||
WHERE id=$1
|
WHERE id=$1
|
||||||
`, [row.id]);
|
`,[row.id]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const label = row.documentKind === 'ACT_PDF'
|
const label = row.documentKind === 'ACT_PDF'
|
||||||
? `Acta ${row.actCode}`
|
? `Acta ${row.actCode}`
|
||||||
: `Informe ${row.reportCode ?? ''}`;
|
: `Informe ${row.reportCode ?? ''}`;
|
||||||
const text = row.documentKind === 'REPORT_WORD'
|
const text = row.documentKind === 'REPORT_WORD'
|
||||||
? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.`
|
? `Se adjunta el INF editable ${row.reportCode ?? ''} para su revisión y preparación antes de cargarlo en GEDO.`
|
||||||
: row.recipientKind === 'INSPECTOR'
|
: row.recipientKind === 'INSPECTOR'
|
||||||
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.`
|
? `Se adjunta copia del acta sellada e inmutable ${row.actCode} correspondiente a tu inspección.`
|
||||||
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`;
|
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
|
||||||
const sent = await this.smtp.send({
|
const sent = await this.smtp.send({
|
||||||
to: row.recipientEmail,
|
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
|
||||||
subject: `DH Inspección · ${label}`,
|
|
||||||
text,
|
|
||||||
attachment,
|
|
||||||
});
|
});
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_document_deliveries
|
UPDATE inspection_document_deliveries
|
||||||
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
|
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
|
||||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1
|
||||||
WHERE id=$1
|
`,[row.id,sent.messageId]);
|
||||||
`, [row.id, sent.messageId]);
|
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
action: AuditAction.DOCUMENT_DELIVERY_SENT,
|
action:AuditAction.DOCUMENT_DELIVERY_SENT,
|
||||||
entityType: 'inspection_document_delivery',
|
entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM,
|
||||||
entityId: row.id,
|
actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null,
|
||||||
source: AuditSource.SYSTEM,
|
afterData:{recipientKind:row.recipientKind,recipientUserId:row.recipientUserId,documentKind:row.documentKind,status:'SENT'},
|
||||||
actorUserId: null,
|
metadata:{actId:row.actId,reportId:row.reportId},
|
||||||
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) {
|
} catch (error) {
|
||||||
await this.setStatus(
|
await this.setStatus(row.id,'FAILED',error instanceof Error ? error.message : 'Error de entrega');
|
||||||
row.id,
|
|
||||||
'FAILED',
|
|
||||||
error instanceof Error ? error.message : 'Error de entrega',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setStatus(id: string, status: string, error: string) {
|
private async setStatus(id: string,status: string,error: string) {
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_document_deliveries
|
UPDATE inspection_document_deliveries
|
||||||
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP
|
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1
|
||||||
WHERE id=$1
|
`,[id,status,error.slice(0,500)]);
|
||||||
`, [id, status, error.slice(0, 500)]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, 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 { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto';
|
|
||||||
import { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto';
|
|
||||||
import { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto';
|
|
||||||
import {
|
|
||||||
MAX_INSPECTION_REPORT_REVISION_BYTES,
|
|
||||||
type UploadedInspectionReportRevisionFile,
|
|
||||||
} from './inspection-report-revision-file';
|
|
||||||
import {
|
|
||||||
InspectionReportReviewService,
|
|
||||||
type InspectionReportReviewView,
|
|
||||||
} from './inspection-report-review.service';
|
|
||||||
|
|
||||||
@Controller('inspection-reports/:reportId/review')
|
|
||||||
export class InspectionReportReviewController {
|
|
||||||
constructor(private readonly review: InspectionReportReviewService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@RequirePermissions('inspection_reports.read')
|
|
||||||
get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string): Promise<InspectionReportReviewView> {
|
|
||||||
return this.review.get(reportId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('revisions')
|
|
||||||
@RequirePermissions('inspection_reports.revise')
|
|
||||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_INSPECTION_REPORT_REVISION_BYTES, files: 1 } }))
|
|
||||||
revision(
|
|
||||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
|
||||||
@Body() dto: CreateInspectionReportRevisionDto,
|
|
||||||
@UploadedFile() file: UploadedInspectionReportRevisionFile | undefined,
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
): Promise<InspectionReportReviewView> {
|
|
||||||
return this.review.createRevision(reportId, dto, file, principal, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('approve')
|
|
||||||
@RequirePermissions('inspection_reports.review')
|
|
||||||
approve(
|
|
||||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
|
||||||
@Body() dto: ApproveInspectionReportDto,
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
): Promise<InspectionReportReviewView> {
|
|
||||||
return this.review.approve(reportId, dto, principal, request);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('sign-final')
|
|
||||||
@RequirePermissions('inspection_reports.sign_final')
|
|
||||||
signFinal(
|
|
||||||
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
|
|
||||||
@Body() dto: SignFinalInspectionReportDto,
|
|
||||||
@CurrentAuth() principal: AuthPrincipal,
|
|
||||||
@Req() request: RequestWithContext,
|
|
||||||
): Promise<InspectionReportReviewView> {
|
|
||||||
return this.review.signFinal(reportId, dto, principal, request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('inspection-report-revisions')
|
|
||||||
export class InspectionReportRevisionContentController {
|
|
||||||
constructor(private readonly review: InspectionReportReviewService) {}
|
|
||||||
|
|
||||||
@Get(':revisionId/content')
|
|
||||||
@RequirePermissions('inspection_reports.read')
|
|
||||||
async content(
|
|
||||||
@Param('revisionId', new ParseUUIDPipe({ version: '4' })) revisionId: string,
|
|
||||||
@Res() response: Response,
|
|
||||||
): Promise<void> {
|
|
||||||
const content = await this.review.revisionContent(revisionId);
|
|
||||||
response.setHeader('Content-Type', content.mimeType);
|
|
||||||
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
|
||||||
response.setHeader('Cache-Control', 'private, no-store');
|
|
||||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
||||||
await new Promise<void>((resolveSend, rejectSend) => {
|
|
||||||
response.sendFile(content.filePath, (error) => {
|
|
||||||
if (error) rejectSend(error);
|
|
||||||
else resolveSend();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
||||||
import { isAbsolute, parse, resolve } from 'node:path';
|
|
||||||
import { ConflictException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { DataSource, EntityManager } from 'typeorm';
|
|
||||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
|
||||||
import { AuditService } from '../audit/audit.service';
|
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
|
||||||
import { AuditAction } from '../database/entities';
|
|
||||||
import { sha256CanonicalJson } from '../inspection-closing/canonical-json';
|
|
||||||
import type { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto';
|
|
||||||
import type { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto';
|
|
||||||
import type { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto';
|
|
||||||
import {
|
|
||||||
INSPECTION_REPORT_WORD_MIME,
|
|
||||||
inspectInspectionReportRevisionUpload,
|
|
||||||
type UploadedInspectionReportRevisionFile,
|
|
||||||
validateInspectionReportRevisionContainer,
|
|
||||||
} from './inspection-report-revision-file';
|
|
||||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
|
||||||
|
|
||||||
export type InspectionReportReviewStatus = 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
|
|
||||||
export type InspectionReportRevisionSource = 'AUTO' | 'DIRECTOR_UPLOAD';
|
|
||||||
|
|
||||||
export interface InspectionReportReviewPerson {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InspectionReportRevisionView {
|
|
||||||
id: string;
|
|
||||||
reportId: string;
|
|
||||||
revisionNumber: number;
|
|
||||||
source: InspectionReportRevisionSource;
|
|
||||||
originalName: string;
|
|
||||||
mimeType: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
sha256: string;
|
|
||||||
changeSummary: string | null;
|
|
||||||
createdAt: Date;
|
|
||||||
createdBy: InspectionReportReviewPerson;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InspectionReportSignatureView {
|
|
||||||
id: string;
|
|
||||||
revisionId: string;
|
|
||||||
signedAt: Date;
|
|
||||||
confirmationText: string;
|
|
||||||
signatureSha256: string;
|
|
||||||
signedBy: InspectionReportReviewPerson;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InspectionReportReviewView {
|
|
||||||
reportId: string;
|
|
||||||
reportCode: string;
|
|
||||||
reviewStatus: InspectionReportReviewStatus;
|
|
||||||
currentRevisionNumber: number;
|
|
||||||
approvedRevisionId: string | null;
|
|
||||||
approvedAt: Date | null;
|
|
||||||
reviewNote: string | null;
|
|
||||||
approvedBy: InspectionReportReviewPerson | null;
|
|
||||||
signature: InspectionReportSignatureView | null;
|
|
||||||
revisions: InspectionReportRevisionView[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LockedReport {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
status: string;
|
|
||||||
frozenSha256: string;
|
|
||||||
reviewStatus: InspectionReportReviewStatus;
|
|
||||||
currentRevisionNumber: number;
|
|
||||||
approvedRevisionId: string | null;
|
|
||||||
approvedAt: Date | null;
|
|
||||||
reviewNote: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StoredRevisionRow {
|
|
||||||
id: string;
|
|
||||||
reportId: string;
|
|
||||||
revisionNumber: number;
|
|
||||||
source: InspectionReportRevisionSource;
|
|
||||||
originalName: string;
|
|
||||||
storedName: string;
|
|
||||||
mimeType: string;
|
|
||||||
sizeBytes: number;
|
|
||||||
sha256: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FINAL_CONFIRMATION_TEXT = 'Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class InspectionReportReviewService {
|
|
||||||
private readonly revisionRoot: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly dataSource: DataSource,
|
|
||||||
private readonly audit: AuditService,
|
|
||||||
private readonly word: InspectionReportWordService,
|
|
||||||
config: ConfigService,
|
|
||||||
) {
|
|
||||||
const configured = config.get<string>('INSPECTION_REPORT_REVISION_ROOT') ?? '/app/storage/asset-media/inspection-report-revisions';
|
|
||||||
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_REVISION_ROOT must be an absolute path');
|
|
||||||
this.revisionRoot = resolve(configured);
|
|
||||||
if (this.revisionRoot === parse(this.revisionRoot).root) throw new Error('INSPECTION_REPORT_REVISION_ROOT cannot be the filesystem root');
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(reportId: string): Promise<InspectionReportReviewView> {
|
|
||||||
await this.word.ensure(reportId);
|
|
||||||
await this.ensureAutomaticRevision(reportId);
|
|
||||||
return this.loadView(reportId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createRevision(
|
|
||||||
reportId: string,
|
|
||||||
dto: CreateInspectionReportRevisionDto,
|
|
||||||
file: UploadedInspectionReportRevisionFile | undefined,
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
): Promise<InspectionReportReviewView> {
|
|
||||||
inspectInspectionReportRevisionUpload(file);
|
|
||||||
await this.assertDirector(principal.userId, this.dataSource.manager);
|
|
||||||
await mkdir(this.revisionRoot, { recursive: true, mode: 0o700 });
|
|
||||||
const storedName = `${reportId}-${randomUUID()}.docx`;
|
|
||||||
const filePath = resolve(this.revisionRoot, storedName);
|
|
||||||
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
|
|
||||||
await writeFile(filePath, file!.buffer, { mode: 0o600 });
|
|
||||||
try {
|
|
||||||
await validateInspectionReportRevisionContainer(filePath);
|
|
||||||
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
|
||||||
const created = await this.dataSource.transaction(async (manager) => {
|
|
||||||
const report = await this.lockReport(manager, reportId);
|
|
||||||
this.assertOpenForReview(report);
|
|
||||||
const nextRevision = report.currentRevisionNumber + 1;
|
|
||||||
const [row] = await manager.query(`
|
|
||||||
INSERT INTO inspection_report_revisions (
|
|
||||||
report_id, revision_number, source, original_name, stored_name, mime_type,
|
|
||||||
size_bytes, sha256, change_summary, created_by
|
|
||||||
) VALUES ($1,$2,'DIRECTOR_UPLOAD',$3,$4,$5,$6,$7,$8,$9)
|
|
||||||
RETURNING id
|
|
||||||
`, [
|
|
||||||
report.id,
|
|
||||||
nextRevision,
|
|
||||||
this.cleanOriginalName(file!.originalname),
|
|
||||||
storedName,
|
|
||||||
INSPECTION_REPORT_WORD_MIME,
|
|
||||||
file!.buffer.length,
|
|
||||||
sha256,
|
|
||||||
dto.changeSummary.trim(),
|
|
||||||
principal.userId,
|
|
||||||
]) as Array<{ id: string }>;
|
|
||||||
await manager.query(`
|
|
||||||
UPDATE inspection_reports
|
|
||||||
SET current_revision_number = $2, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $1
|
|
||||||
`, [report.id, nextRevision]);
|
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.INSPECTION_REPORT_REVISION_ADDED,
|
|
||||||
entityType: 'inspection_report_revision',
|
|
||||||
entityId: row.id,
|
|
||||||
afterData: {
|
|
||||||
reportId: report.id,
|
|
||||||
reportCode: report.code,
|
|
||||||
revisionNumber: nextRevision,
|
|
||||||
sha256,
|
|
||||||
source: 'DIRECTOR_UPLOAD',
|
|
||||||
},
|
|
||||||
}, manager);
|
|
||||||
return row.id;
|
|
||||||
});
|
|
||||||
if (!created) throw this.storageError();
|
|
||||||
} catch (error) {
|
|
||||||
await rm(filePath, { force: true }).catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return this.loadView(reportId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async approve(
|
|
||||||
reportId: string,
|
|
||||||
dto: ApproveInspectionReportDto,
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
): Promise<InspectionReportReviewView> {
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
|
||||||
await this.assertDirector(principal.userId, manager);
|
|
||||||
const report = await this.lockReport(manager, reportId);
|
|
||||||
this.assertOpenForReview(report);
|
|
||||||
const [revision] = await manager.query(`
|
|
||||||
SELECT id, revision_number AS "revisionNumber", sha256
|
|
||||||
FROM inspection_report_revisions
|
|
||||||
WHERE report_id = $1 AND revision_number = $2
|
|
||||||
`, [report.id, report.currentRevisionNumber]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
|
|
||||||
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_REVISION_REQUIRED', message: 'El informe necesita una versión Word válida antes de aprobarse' });
|
|
||||||
const approvedAt = new Date();
|
|
||||||
await manager.query(`
|
|
||||||
UPDATE inspection_reports
|
|
||||||
SET review_status = 'APPROVED', approved_revision_id = $2, approved_by = $3,
|
|
||||||
approved_at = $4, review_note = $5, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $1
|
|
||||||
`, [report.id, revision.id, principal.userId, approvedAt, dto.note?.trim() || null]);
|
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.INSPECTION_REPORT_APPROVED,
|
|
||||||
entityType: 'inspection_report',
|
|
||||||
entityId: report.id,
|
|
||||||
afterData: {
|
|
||||||
reportCode: report.code,
|
|
||||||
revisionId: revision.id,
|
|
||||||
revisionNumber: revision.revisionNumber,
|
|
||||||
revisionSha256: revision.sha256,
|
|
||||||
approvedAt: approvedAt.toISOString(),
|
|
||||||
},
|
|
||||||
}, manager);
|
|
||||||
});
|
|
||||||
return this.loadView(reportId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async signFinal(
|
|
||||||
reportId: string,
|
|
||||||
dto: SignFinalInspectionReportDto,
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
): Promise<InspectionReportReviewView> {
|
|
||||||
if (dto.confirmation !== true) throw new ConflictException({ code: 'INSPECTION_REPORT_SIGNATURE_CONFIRMATION_REQUIRED', message: 'Debés confirmar expresamente la firma final del informe' });
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
|
||||||
await this.assertDirector(principal.userId, manager);
|
|
||||||
const report = await this.lockReport(manager, reportId);
|
|
||||||
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe ya tiene una firma final inmutable' });
|
|
||||||
if (report.reviewStatus !== 'APPROVED' || !report.approvedRevisionId || !report.approvedAt) {
|
|
||||||
throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_APPROVED', message: 'El Director debe aprobar una versión antes de firmar el informe final' });
|
|
||||||
}
|
|
||||||
const [revision] = await manager.query(`
|
|
||||||
SELECT id, revision_number AS "revisionNumber", sha256
|
|
||||||
FROM inspection_report_revisions
|
|
||||||
WHERE id = $1 AND report_id = $2
|
|
||||||
`, [report.approvedRevisionId, report.id]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
|
|
||||||
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_APPROVED_REVISION_MISSING', message: 'La versión aprobada no está disponible para la firma final' });
|
|
||||||
const signedAt = new Date();
|
|
||||||
const payload = {
|
|
||||||
schemaVersion: 'DH-INSPECTION-REPORT-SIGNATURE-V1',
|
|
||||||
reportId: report.id,
|
|
||||||
reportCode: report.code,
|
|
||||||
reportFrozenSha256: report.frozenSha256,
|
|
||||||
revisionId: revision.id,
|
|
||||||
revisionNumber: revision.revisionNumber,
|
|
||||||
revisionSha256: revision.sha256,
|
|
||||||
signedBy: principal.userId,
|
|
||||||
signedByUsername: principal.username,
|
|
||||||
signedAt: signedAt.toISOString(),
|
|
||||||
confirmationText: FINAL_CONFIRMATION_TEXT,
|
|
||||||
};
|
|
||||||
const signatureSha256 = sha256CanonicalJson(payload);
|
|
||||||
const [signature] = await manager.query(`
|
|
||||||
INSERT INTO inspection_report_signatures (
|
|
||||||
report_id, revision_id, signed_by, signed_at, confirmation_text,
|
|
||||||
signature_payload, signature_sha256
|
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
||||||
RETURNING id
|
|
||||||
`, [report.id, revision.id, principal.userId, signedAt, FINAL_CONFIRMATION_TEXT, payload, signatureSha256]) as Array<{ id: string }>;
|
|
||||||
await manager.query(`
|
|
||||||
UPDATE inspection_reports
|
|
||||||
SET review_status = 'SIGNED', signed_at = $2, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $1
|
|
||||||
`, [report.id, signedAt]);
|
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.INSPECTION_REPORT_SIGNED,
|
|
||||||
entityType: 'inspection_report_signature',
|
|
||||||
entityId: signature.id,
|
|
||||||
afterData: {
|
|
||||||
reportId: report.id,
|
|
||||||
reportCode: report.code,
|
|
||||||
revisionId: revision.id,
|
|
||||||
revisionNumber: revision.revisionNumber,
|
|
||||||
revisionSha256: revision.sha256,
|
|
||||||
signatureSha256,
|
|
||||||
signedAt: signedAt.toISOString(),
|
|
||||||
},
|
|
||||||
}, manager);
|
|
||||||
});
|
|
||||||
return this.loadView(reportId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async revisionContent(revisionId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
|
||||||
const [revision] = await this.dataSource.query(`
|
|
||||||
SELECT id, report_id AS "reportId", revision_number AS "revisionNumber", source,
|
|
||||||
original_name AS "originalName", stored_name AS "storedName", mime_type AS "mimeType",
|
|
||||||
size_bytes::integer AS "sizeBytes", sha256
|
|
||||||
FROM inspection_report_revisions WHERE id = $1
|
|
||||||
`, [revisionId]) as StoredRevisionRow[];
|
|
||||||
if (!revision) throw new NotFoundException({ code: 'INSPECTION_REPORT_REVISION_NOT_FOUND', message: 'Versión del informe no encontrada' });
|
|
||||||
if (revision.source === 'AUTO') return this.word.content(revision.reportId);
|
|
||||||
const filePath = resolve(this.revisionRoot, revision.storedName);
|
|
||||||
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
|
|
||||||
const fileStat = await stat(filePath).catch(() => null);
|
|
||||||
if (!fileStat?.isFile() || fileStat.size !== revision.sizeBytes) throw this.storageError();
|
|
||||||
const buffer = await readFile(filePath);
|
|
||||||
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
|
||||||
if (sha256 !== revision.sha256) throw this.storageError();
|
|
||||||
return { filePath, originalName: revision.originalName, mimeType: revision.mimeType };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureAutomaticRevision(reportId: string): Promise<void> {
|
|
||||||
await this.dataSource.query(`
|
|
||||||
INSERT INTO inspection_report_revisions (
|
|
||||||
report_id, revision_number, source, original_name, stored_name, mime_type,
|
|
||||||
size_bytes, sha256, change_summary, created_by, created_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type,
|
|
||||||
word_size_bytes, word_sha256, 'Versión automática inicial', generated_by,
|
|
||||||
COALESCE(word_generated_at, generated_at)
|
|
||||||
FROM inspection_reports
|
|
||||||
WHERE id = $1
|
|
||||||
AND word_status = 'READY'
|
|
||||||
AND word_original_name IS NOT NULL
|
|
||||||
AND word_stored_name IS NOT NULL
|
|
||||||
AND word_mime_type = $2
|
|
||||||
AND word_size_bytes > 0
|
|
||||||
AND word_sha256 ~ '^[0-9a-f]{64}$'
|
|
||||||
ON CONFLICT (report_id, revision_number) DO NOTHING
|
|
||||||
`, [reportId, INSPECTION_REPORT_WORD_MIME]);
|
|
||||||
await this.dataSource.query(`
|
|
||||||
UPDATE inspection_reports
|
|
||||||
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = $1
|
|
||||||
AND EXISTS (SELECT 1 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = 1)
|
|
||||||
`, [reportId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadView(reportId: string): Promise<InspectionReportReviewView> {
|
|
||||||
const [report] = await this.dataSource.query(`
|
|
||||||
SELECT
|
|
||||||
report.id AS "reportId",
|
|
||||||
report.code AS "reportCode",
|
|
||||||
report.review_status AS "reviewStatus",
|
|
||||||
report.current_revision_number AS "currentRevisionNumber",
|
|
||||||
report.approved_revision_id AS "approvedRevisionId",
|
|
||||||
report.approved_at AS "approvedAt",
|
|
||||||
report.review_note AS "reviewNote",
|
|
||||||
CASE WHEN approver.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
||||||
'id', approver.id, 'username', approver.username, 'firstName', approver.first_name, 'lastName', approver.last_name
|
|
||||||
) END AS "approvedBy"
|
|
||||||
FROM inspection_reports report
|
|
||||||
LEFT JOIN users approver ON approver.id = report.approved_by
|
|
||||||
WHERE report.id = $1
|
|
||||||
`, [reportId]) as Array<Omit<InspectionReportReviewView, 'signature' | 'revisions'>>;
|
|
||||||
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
|
|
||||||
const revisions = await this.dataSource.query(`
|
|
||||||
SELECT
|
|
||||||
revision.id,
|
|
||||||
revision.report_id AS "reportId",
|
|
||||||
revision.revision_number AS "revisionNumber",
|
|
||||||
revision.source,
|
|
||||||
revision.original_name AS "originalName",
|
|
||||||
revision.mime_type AS "mimeType",
|
|
||||||
revision.size_bytes::integer AS "sizeBytes",
|
|
||||||
revision.sha256,
|
|
||||||
revision.change_summary AS "changeSummary",
|
|
||||||
revision.created_at AS "createdAt",
|
|
||||||
JSONB_BUILD_OBJECT(
|
|
||||||
'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name
|
|
||||||
) AS "createdBy"
|
|
||||||
FROM inspection_report_revisions revision
|
|
||||||
JOIN users creator ON creator.id = revision.created_by
|
|
||||||
WHERE revision.report_id = $1
|
|
||||||
ORDER BY revision.revision_number DESC
|
|
||||||
`, [reportId]) as InspectionReportRevisionView[];
|
|
||||||
const [signature] = await this.dataSource.query(`
|
|
||||||
SELECT
|
|
||||||
signature.id,
|
|
||||||
signature.revision_id AS "revisionId",
|
|
||||||
signature.signed_at AS "signedAt",
|
|
||||||
signature.confirmation_text AS "confirmationText",
|
|
||||||
signature.signature_sha256 AS "signatureSha256",
|
|
||||||
JSONB_BUILD_OBJECT(
|
|
||||||
'id', signer.id, 'username', signer.username, 'firstName', signer.first_name, 'lastName', signer.last_name
|
|
||||||
) AS "signedBy"
|
|
||||||
FROM inspection_report_signatures signature
|
|
||||||
JOIN users signer ON signer.id = signature.signed_by
|
|
||||||
WHERE signature.report_id = $1
|
|
||||||
`, [reportId]) as InspectionReportSignatureView[];
|
|
||||||
return { ...report, signature: signature ?? null, revisions };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async lockReport(manager: EntityManager, reportId: string): Promise<LockedReport> {
|
|
||||||
const [report] = await manager.query(`
|
|
||||||
SELECT
|
|
||||||
id, code, status, frozen_sha256 AS "frozenSha256",
|
|
||||||
review_status AS "reviewStatus",
|
|
||||||
current_revision_number AS "currentRevisionNumber",
|
|
||||||
approved_revision_id AS "approvedRevisionId",
|
|
||||||
approved_at AS "approvedAt",
|
|
||||||
review_note AS "reviewNote"
|
|
||||||
FROM inspection_reports
|
|
||||||
WHERE id = $1
|
|
||||||
FOR UPDATE
|
|
||||||
`, [reportId]) as LockedReport[];
|
|
||||||
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
|
|
||||||
if (report.status !== 'FROZEN') throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_REVIEWABLE', message: 'Sólo los informes congelados pueden ingresar al circuito de revisión' });
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertOpenForReview(report: LockedReport): void {
|
|
||||||
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe firmado es definitivo y no admite nuevas versiones' });
|
|
||||||
if (report.reviewStatus === 'APPROVED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_APPROVED', message: 'La versión ya fue aprobada y sólo resta la firma final del Director' });
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertDirector(userId: string, manager: EntityManager): Promise<void> {
|
|
||||||
const [role] = await manager.query(`
|
|
||||||
SELECT role.code
|
|
||||||
FROM user_roles membership
|
|
||||||
JOIN roles role ON role.id = membership.role_id
|
|
||||||
WHERE membership.user_id = $1 AND role.code = 'director'
|
|
||||||
LIMIT 1
|
|
||||||
`, [userId]) as Array<{ code: string }>;
|
|
||||||
if (!role) throw new ForbiddenException({ code: 'INSPECTION_REPORT_DIRECTOR_REQUIRED', message: 'Esta operación está reservada al Director de Hidrocarburos' });
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanOriginalName(value: string): string {
|
|
||||||
const cleaned = value.replace(/[\\/\0\r\n]/g, '_').trim();
|
|
||||||
return (cleaned || 'informe-corregido.docx').slice(0, 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
private storageError(): InternalServerErrorException {
|
|
||||||
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_REVISION_STORAGE_ERROR', message: 'La versión del informe no está disponible o no supera la validación de integridad' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,8 @@ interface ReportWordInput {
|
|||||||
generatedAt: Date;
|
generatedAt: Date;
|
||||||
frozenSha256: string;
|
frozenSha256: string;
|
||||||
frozenSnapshot: Record<string, unknown>;
|
frozenSnapshot: Record<string, unknown>;
|
||||||
|
executiveSummary?: string | null;
|
||||||
|
reportDescription?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const crcTable = (() => {
|
const crcTable = (() => {
|
||||||
@@ -56,8 +58,8 @@ function text(value: unknown, fallback = '—'): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isoDate(value: unknown): string {
|
function isoDate(value: unknown): string {
|
||||||
const date = new Date(String(value ?? ''));
|
const parsed = new Date(String(value ?? ''));
|
||||||
return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—';
|
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string {
|
function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string {
|
||||||
@@ -76,52 +78,96 @@ function table(headers: string[], rows: string[][]): string {
|
|||||||
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="B7C9D6"/><w:left w:val="single" w:sz="4" w:color="B7C9D6"/><w:bottom w:val="single" w:sz="4" w:color="B7C9D6"/><w:right w:val="single" w:sz="4" w:color="B7C9D6"/><w:insideH w:val="single" w:sz="4" w:color="D8E1E8"/><w:insideV w:val="single" w:sz="4" w:color="D8E1E8"/></w:tblBorders></w:tblPr>${header}${body}</w:tbl>`;
|
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="B7C9D6"/><w:left w:val="single" w:sz="4" w:color="B7C9D6"/><w:bottom w:val="single" w:sz="4" w:color="B7C9D6"/><w:right w:val="single" w:sz="4" w:color="B7C9D6"/><w:insideH w:val="single" w:sz="4" w:color="D8E1E8"/><w:insideV w:val="single" w:sz="4" w:color="D8E1E8"/></w:tblBorders></w:tblPr>${header}${body}</w:tbl>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function f4Snapshot(input: ReportWordInput) {
|
||||||
|
const source = asRecord(input.frozenSnapshot.source);
|
||||||
|
const sealedAct = asRecord(input.frozenSnapshot.sealedAct ?? input.frozenSnapshot.actClosure);
|
||||||
|
const locked = asRecord(sealedAct.lockedSnapshot ?? sealedAct.preparedSnapshot);
|
||||||
|
const act = asRecord(locked.act);
|
||||||
|
const inspection = asRecord(act.inspection ?? act.visit);
|
||||||
|
const responsible = asRecord(locked.responsible);
|
||||||
|
const inventories = asArray(locked.inventories ?? locked.assets);
|
||||||
|
const findings = asArray(locked.findings);
|
||||||
|
const signatures = asArray(sealedAct.signatures);
|
||||||
|
return { source, sealedAct, locked, act, inspection, responsible, inventories, findings, signatures };
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgency(value: unknown): string {
|
||||||
|
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deadline(act: Record<string, unknown>): string {
|
||||||
|
const type = text(act.deadlineDayType, '') === 'CALENDAR' ? 'días corridos' : 'días hábiles';
|
||||||
|
if (act.deadlineAt) return `${isoDate(act.deadlineAt)} · ${text(act.deadlineDays)} ${type}`;
|
||||||
|
if (text(act.deadlineBasis, '') === 'GEDO_DATE') return `${text(act.deadlineDays)} ${type} desde la fecha de oficialización GEDO`;
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
function documentXml(input: ReportWordInput): string {
|
function documentXml(input: ReportWordInput): string {
|
||||||
const report = asRecord(input.frozenSnapshot.report);
|
const snapshot = f4Snapshot(input);
|
||||||
const actClosure = asRecord(input.frozenSnapshot.actClosure);
|
const findingRows = snapshot.findings.map((item) => [
|
||||||
const prepared = asRecord(actClosure.preparedSnapshot);
|
text(item.code),
|
||||||
const act = asRecord(prepared.act);
|
text(item.title),
|
||||||
const visit = asRecord(act.visit);
|
text(item.description),
|
||||||
const responsible = asRecord(prepared.responsible);
|
item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
|
||||||
const team = asArray(prepared.team);
|
item.severity == null ? '—' : `${text(item.severity)}/10`,
|
||||||
const assets = asArray(prepared.assets);
|
]);
|
||||||
const findings = asArray(prepared.findings);
|
const inventoryRows = snapshot.inventories.map((item) => [
|
||||||
const signatures = asArray(actClosure.signatures);
|
text(item.code),
|
||||||
const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))];
|
text(item.name),
|
||||||
const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))];
|
text(item.typeName ?? item.typeCode),
|
||||||
const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean);
|
]);
|
||||||
const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]);
|
const signatureRows = snapshot.signatures.map((item) => [
|
||||||
const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]);
|
text(item.signerType),
|
||||||
const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]);
|
text(item.signerName),
|
||||||
|
text(item.status),
|
||||||
|
text(item.companyManifestation, ''),
|
||||||
|
isoDate(item.signedAt ?? item.createdAt),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const executive = input.executiveSummary?.trim()
|
||||||
|
|| '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.';
|
||||||
|
const description = input.reportDescription?.trim()
|
||||||
|
|| '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.';
|
||||||
|
|
||||||
const body = [
|
const body = [
|
||||||
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
|
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
|
||||||
paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'),
|
paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'),
|
||||||
labelValue('Informe', input.code),
|
labelValue('Informe', input.code),
|
||||||
labelValue('Título', input.title),
|
labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)),
|
||||||
|
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
|
||||||
|
labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)),
|
||||||
|
labelValue('Urgencia', urgency(snapshot.act.urgency)),
|
||||||
|
labelValue('Plazo', deadline(snapshot.act)),
|
||||||
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
|
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
|
||||||
labelValue('Acta', text(report.actCode ?? act.code)),
|
paragraph('Resumen ejecutivo', 'Heading1'),
|
||||||
labelValue('Inspección', text(report.visitCode ?? visit.code)),
|
paragraph(executive),
|
||||||
labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'),
|
paragraph('Descripción / análisis técnico', 'Heading1'),
|
||||||
labelValue('Área', areas.join(' · ') || 'Según alcance del acta'),
|
paragraph(description),
|
||||||
labelValue('Inspector/es', inspectorNames.join(' · ') || '—'),
|
paragraph('Acta fuente · contenido inmutable', 'Heading1'),
|
||||||
labelValue('Responsable de empresa', text(responsible.fullName)),
|
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
|
||||||
labelValue('Cargo', text(responsible.position)),
|
labelValue('Resumen del Acta', text(snapshot.act.summary)),
|
||||||
paragraph('Datos de la visita', 'Heading1'),
|
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
|
||||||
labelValue('Objetivo', text(visit.objective)),
|
labelValue('Responsable de empresa', text(snapshot.responsible.fullName)),
|
||||||
labelValue('Fecha de inspección', isoDate(act.occurredAt)),
|
labelValue('Cargo', text(snapshot.responsible.position)),
|
||||||
labelValue('Resumen', text(act.summary)),
|
paragraph('Inventario inspeccionado', 'Heading1'),
|
||||||
labelValue('Observaciones', text(act.observations)),
|
inventoryRows.length
|
||||||
paragraph('Elementos inspeccionados', 'Heading1'),
|
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
|
||||||
assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'),
|
: paragraph('No se registraron elementos de Inventario en el Acta.'),
|
||||||
paragraph('Hallazgos', 'Heading1'),
|
paragraph('Hallazgos', 'Heading1'),
|
||||||
findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'),
|
findingRows.length
|
||||||
paragraph('Firmas y constancias', 'Heading1'),
|
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
|
||||||
signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'),
|
: paragraph('El Acta no contiene Hallazgos.'),
|
||||||
paragraph('Integridad documental', 'Heading1'),
|
paragraph('Firmas y manifestaciones', 'Heading1'),
|
||||||
labelValue('Hash del informe', input.frozenSha256),
|
signatureRows.length
|
||||||
labelValue('Hash de cierre del acta', text(report.actClosureSha256)),
|
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
|
||||||
paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'),
|
: paragraph('No se registraron firmas en la instantánea sellada.'),
|
||||||
|
paragraph('Integridad de la fuente', 'Heading1'),
|
||||||
|
labelValue('Hash de la fuente del INF', input.frozenSha256),
|
||||||
|
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
|
||||||
|
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)),
|
||||||
|
paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'),
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +193,7 @@ function buildZip(entries: ZipEntry[]): Buffer {
|
|||||||
local.writeUInt16LE(name.length, 26);
|
local.writeUInt16LE(name.length, 26);
|
||||||
local.writeUInt16LE(0, 28);
|
local.writeUInt16LE(0, 28);
|
||||||
locals.push(local, name, entry.data);
|
locals.push(local, name, entry.data);
|
||||||
|
|
||||||
const central = Buffer.alloc(46);
|
const central = Buffer.alloc(46);
|
||||||
central.writeUInt32LE(0x02014b50, 0);
|
central.writeUInt32LE(0x02014b50, 0);
|
||||||
central.writeUInt16LE(20, 4);
|
central.writeUInt16LE(20, 4);
|
||||||
@@ -168,6 +215,7 @@ function buildZip(entries: ZipEntry[]): Buffer {
|
|||||||
centrals.push(central, name);
|
centrals.push(central, name);
|
||||||
offset += local.length + name.length + entry.data.length;
|
offset += local.length + name.length + entry.data.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const centralData = Buffer.concat(centrals);
|
const centralData = Buffer.concat(centrals);
|
||||||
const end = Buffer.alloc(22);
|
const end = Buffer.alloc(22);
|
||||||
end.writeUInt32LE(0x06054b50, 0);
|
end.writeUInt32LE(0x06054b50, 0);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface WordRow {
|
|||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
executiveSummary: string | null;
|
||||||
|
reportDescription: string | null;
|
||||||
generatedAt: Date;
|
generatedAt: Date;
|
||||||
frozenSha256: string;
|
frozenSha256: string;
|
||||||
frozenSnapshot: Record<string, unknown>;
|
frozenSnapshot: Record<string, unknown>;
|
||||||
@@ -29,7 +31,8 @@ export class InspectionReportWordService {
|
|||||||
private readonly root: string;
|
private readonly root: string;
|
||||||
|
|
||||||
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
constructor(private readonly dataSource: DataSource, config: ConfigService) {
|
||||||
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT') ?? '/app/storage/asset-media/inspection-reports-word';
|
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
|
||||||
|
?? '/app/storage/asset-media/inspection-reports-word';
|
||||||
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
|
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
|
||||||
this.root = resolve(configured);
|
this.root = resolve(configured);
|
||||||
if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root');
|
if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root');
|
||||||
@@ -38,7 +41,11 @@ export class InspectionReportWordService {
|
|||||||
async ensure(reportId: string): Promise<void> {
|
async ensure(reportId: string): Promise<void> {
|
||||||
const row = await this.load(reportId);
|
const row = await this.load(reportId);
|
||||||
if (row.wordStatus === 'READY' && row.wordStoredName) {
|
if (row.wordStatus === 'READY' && row.wordStoredName) {
|
||||||
try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {}
|
try {
|
||||||
|
await this.content(row.id);
|
||||||
|
await this.ensureInitialRevision(row);
|
||||||
|
return;
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const built = buildInspectionReportWord({
|
const built = buildInspectionReportWord({
|
||||||
@@ -47,40 +54,51 @@ export class InspectionReportWordService {
|
|||||||
generatedAt: new Date(row.generatedAt),
|
generatedAt: new Date(row.generatedAt),
|
||||||
frozenSha256: row.frozenSha256,
|
frozenSha256: row.frozenSha256,
|
||||||
frozenSnapshot: row.frozenSnapshot,
|
frozenSnapshot: row.frozenSnapshot,
|
||||||
|
executiveSummary: row.executiveSummary,
|
||||||
|
reportDescription: row.reportDescription,
|
||||||
});
|
});
|
||||||
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
const storedName = `${row.id}.docx`;
|
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
|
||||||
const originalName = `${row.code}.docx`;
|
const originalName = `${row.code}.docx`;
|
||||||
const filePath = resolve(this.root, storedName);
|
const filePath = resolve(this.root, storedName);
|
||||||
await writeFile(filePath, built.buffer, { mode: 0o600 });
|
await writeFile(filePath, built.buffer, { mode: 0o600 });
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_reports
|
UPDATE inspection_reports
|
||||||
SET word_status = 'READY',
|
SET word_status='READY',
|
||||||
word_original_name = $2,
|
word_original_name=$2,
|
||||||
word_stored_name = $3,
|
word_stored_name=$3,
|
||||||
word_mime_type = $4,
|
word_mime_type=$4,
|
||||||
word_size_bytes = $5,
|
word_size_bytes=$5,
|
||||||
word_sha256 = $6,
|
word_sha256=$6,
|
||||||
word_generated_at = CURRENT_TIMESTAMP,
|
word_generated_at=CURRENT_TIMESTAMP,
|
||||||
word_error = NULL,
|
word_error=NULL,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at=CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
WHERE id=$1
|
||||||
`, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]);
|
`, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]);
|
||||||
await this.ensureInitialRevision(await this.load(row.id));
|
await this.ensureInitialRevision(await this.load(row.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
|
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_reports
|
UPDATE inspection_reports
|
||||||
SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP
|
SET word_status='FAILED',word_error=$2,updated_at=CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
WHERE id=$1
|
||||||
`, [row.id, message]).catch(() => undefined);
|
`, [row.id, message]).catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||||
const row = await this.load(reportId);
|
const row = await this.load(reportId);
|
||||||
if (row.wordStatus !== 'READY' || !row.wordStoredName || !row.wordOriginalName || !row.wordSha256 || !row.wordSizeBytes) {
|
if (
|
||||||
throw new NotFoundException({ code: 'INSPECTION_REPORT_WORD_NOT_READY', message: 'El archivo Word del informe todavía no está disponible' });
|
row.wordStatus !== 'READY'
|
||||||
|
|| !row.wordStoredName
|
||||||
|
|| !row.wordOriginalName
|
||||||
|
|| !row.wordSha256
|
||||||
|
|| !row.wordSizeBytes
|
||||||
|
) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_REPORT_WORD_NOT_READY',
|
||||||
|
message: 'El archivo Word del informe todavía no está disponible',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const filePath = resolve(this.root, row.wordStoredName);
|
const filePath = resolve(this.root, row.wordStoredName);
|
||||||
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
|
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
|
||||||
@@ -89,40 +107,78 @@ export class InspectionReportWordService {
|
|||||||
const buffer = await readFile(filePath);
|
const buffer = await readFile(filePath);
|
||||||
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
||||||
if (sha256 !== row.wordSha256) throw this.storageError();
|
if (sha256 !== row.wordSha256) throw this.storageError();
|
||||||
return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME };
|
return {
|
||||||
|
filePath,
|
||||||
|
originalName: row.wordOriginalName,
|
||||||
|
mimeType: row.wordMimeType ?? WORD_MIME,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async ensureInitialRevision(row: WordRow): Promise<void> {
|
private async ensureInitialRevision(row: WordRow): Promise<void> {
|
||||||
if (row.wordStatus !== 'READY' || !row.wordOriginalName || !row.wordStoredName || !row.wordMimeType || !row.wordSizeBytes || !row.wordSha256) return;
|
if (
|
||||||
|
row.wordStatus !== 'READY'
|
||||||
|
|| !row.wordOriginalName
|
||||||
|
|| !row.wordStoredName
|
||||||
|
|| !row.wordMimeType
|
||||||
|
|| !row.wordSizeBytes
|
||||||
|
|| !row.wordSha256
|
||||||
|
) return;
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
INSERT INTO inspection_report_revisions (
|
INSERT INTO inspection_report_revisions (
|
||||||
report_id, revision_number, source, original_name, stored_name, mime_type,
|
report_id,revision_number,source,original_name,stored_name,mime_type,
|
||||||
size_bytes, sha256, change_summary, created_by, created_at
|
size_bytes,sha256,change_summary,created_by,created_at
|
||||||
) VALUES ($1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial',$7,COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP))
|
) VALUES (
|
||||||
ON CONFLICT (report_id, revision_number) DO NOTHING
|
$1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial editable',$7,
|
||||||
`, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]);
|
COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)
|
||||||
|
)
|
||||||
|
ON CONFLICT (report_id,revision_number) DO NOTHING
|
||||||
|
`, [
|
||||||
|
row.id,
|
||||||
|
row.wordOriginalName,
|
||||||
|
row.wordStoredName,
|
||||||
|
row.wordMimeType,
|
||||||
|
row.wordSizeBytes,
|
||||||
|
row.wordSha256,
|
||||||
|
row.generatedBy,
|
||||||
|
]);
|
||||||
await this.dataSource.query(`
|
await this.dataSource.query(`
|
||||||
UPDATE inspection_reports
|
UPDATE inspection_reports
|
||||||
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
|
SET current_revision_number=GREATEST(current_revision_number,1),updated_at=CURRENT_TIMESTAMP
|
||||||
WHERE id = $1
|
WHERE id=$1
|
||||||
`, [row.id]);
|
`, [row.id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async load(reportId: string): Promise<WordRow> {
|
private async load(reportId: string): Promise<WordRow> {
|
||||||
const [row] = await this.dataSource.query(`
|
const [row] = await this.dataSource.query(`
|
||||||
SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256",
|
SELECT id,code,title,
|
||||||
frozen_snapshot AS "frozenSnapshot", word_status AS "wordStatus",
|
executive_summary AS "executiveSummary",
|
||||||
word_original_name AS "wordOriginalName", word_stored_name AS "wordStoredName",
|
report_description AS "reportDescription",
|
||||||
word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes",
|
generated_at AS "generatedAt",
|
||||||
word_sha256 AS "wordSha256", generated_by AS "generatedBy"
|
frozen_sha256 AS "frozenSha256",
|
||||||
FROM inspection_reports WHERE id = $1
|
frozen_snapshot AS "frozenSnapshot",
|
||||||
|
word_status AS "wordStatus",
|
||||||
|
word_original_name AS "wordOriginalName",
|
||||||
|
word_stored_name AS "wordStoredName",
|
||||||
|
word_mime_type AS "wordMimeType",
|
||||||
|
word_size_bytes::integer AS "wordSizeBytes",
|
||||||
|
word_sha256 AS "wordSha256",
|
||||||
|
generated_by AS "generatedBy"
|
||||||
|
FROM inspection_reports
|
||||||
|
WHERE id=$1
|
||||||
`, [reportId]) as WordRow[];
|
`, [reportId]) as WordRow[];
|
||||||
if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
|
if (!row) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_REPORT_NOT_FOUND',
|
||||||
|
message: 'Informe de inspección no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
private storageError(): InternalServerErrorException {
|
private storageError(): InternalServerErrorException {
|
||||||
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', message: 'El archivo Word del informe no está disponible o no supera la validación de integridad' });
|
return new InternalServerErrorException({
|
||||||
|
code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR',
|
||||||
|
message: 'El archivo Word del informe no está disponible o no supera la validación de integridad',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
|
||||||
|
import { isAbsolute, parse, resolve } from 'node:path';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import {
|
||||||
|
AuditAction,
|
||||||
|
InspectionReportStatus,
|
||||||
|
} from '../database/entities';
|
||||||
|
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||||
|
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||||
|
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
|
||||||
|
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface UploadedInspectionReportFile {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalname: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportRow {
|
||||||
|
id: string;
|
||||||
|
actId: string;
|
||||||
|
visitId: string;
|
||||||
|
code: string;
|
||||||
|
status: InspectionReportStatus;
|
||||||
|
executiveSummary: string | null;
|
||||||
|
reportDescription: string | null;
|
||||||
|
gedoIfIdentifier: string | null;
|
||||||
|
gedoOfficializedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportNotFound(): NotFoundException {
|
||||||
|
return new NotFoundException({
|
||||||
|
code: 'INSPECTION_REPORT_NOT_FOUND',
|
||||||
|
message: 'Informe de inspección no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InspectionReportWorkflowService {
|
||||||
|
private readonly root: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
config: ConfigService,
|
||||||
|
) {
|
||||||
|
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
|
||||||
|
?? '/app/storage/asset-media/inspection-reports';
|
||||||
|
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute');
|
||||||
|
this.root = resolve(configured);
|
||||||
|
if (this.root === parse(this.root).root) {
|
||||||
|
throw new Error('INSPECTION_REPORT_UPLOAD_ROOT cannot be filesystem root');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNarrative(
|
||||||
|
reportId: string,
|
||||||
|
dto: UpdateInspectionReportDto,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
if (Object.keys(dto).length === 0) {
|
||||||
|
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
||||||
|
}
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const report = await this.lockReport(manager, reportId);
|
||||||
|
if (report.status !== InspectionReportStatus.WORKING) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
|
||||||
|
message: 'El contenido editable del INF queda cerrado cuando se registra su IF oficial de GEDO',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const before = {
|
||||||
|
executiveSummary: report.executiveSummary,
|
||||||
|
reportDescription: report.reportDescription,
|
||||||
|
};
|
||||||
|
const executiveSummary = dto.executiveSummary !== undefined
|
||||||
|
? dto.executiveSummary
|
||||||
|
: report.executiveSummary;
|
||||||
|
const reportDescription = dto.description !== undefined
|
||||||
|
? dto.description
|
||||||
|
: report.reportDescription;
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE inspection_reports
|
||||||
|
SET executive_summary=$2,
|
||||||
|
report_description=$3,
|
||||||
|
word_status=CASE
|
||||||
|
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
|
||||||
|
THEN 'PENDING' ELSE word_status END,
|
||||||
|
word_original_name=CASE
|
||||||
|
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
|
||||||
|
THEN NULL ELSE word_original_name END,
|
||||||
|
word_stored_name=CASE
|
||||||
|
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
|
||||||
|
THEN NULL ELSE word_stored_name END,
|
||||||
|
word_mime_type=CASE
|
||||||
|
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
|
||||||
|
THEN NULL ELSE word_mime_type END,
|
||||||
|
word_size_bytes=CASE
|
||||||
|
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
|
||||||
|
THEN NULL ELSE word_size_bytes END,
|
||||||
|
word_sha256=CASE
|
||||||
|
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
|
||||||
|
THEN NULL ELSE word_sha256 END,
|
||||||
|
word_generated_at=CASE
|
||||||
|
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
|
||||||
|
THEN NULL ELSE word_generated_at END,
|
||||||
|
word_error=CASE
|
||||||
|
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
|
||||||
|
THEN NULL ELSE word_error END,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
`, [reportId, executiveSummary, reportDescription]);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_REPORT_UPDATED,
|
||||||
|
entityType: 'inspection_report',
|
||||||
|
entityId: reportId,
|
||||||
|
beforeData: before,
|
||||||
|
afterData: { executiveSummary, reportDescription },
|
||||||
|
metadata: { reportCode: report.code, actId: report.actId },
|
||||||
|
}, manager);
|
||||||
|
return this.getWorkflowView(manager, reportId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async officialize(
|
||||||
|
reportId: string,
|
||||||
|
dto: OfficializeInspectionReportDto,
|
||||||
|
file: UploadedInspectionReportFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
this.assertPdf(file);
|
||||||
|
const id = randomUUID();
|
||||||
|
const storedName = `gedo-${id}.pdf`;
|
||||||
|
const filePath = resolve(this.root, storedName);
|
||||||
|
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||||
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
|
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const report = await this.lockReport(manager, reportId);
|
||||||
|
if (report.status !== InspectionReportStatus.WORKING) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
|
||||||
|
message: 'El IF oficial de GEDO ya fue registrado y es inmutable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const officializedAt = new Date(dto.gedoOfficializedAt);
|
||||||
|
const now = new Date();
|
||||||
|
if (officializedAt.getTime() > now.getTime() + 24 * 60 * 60 * 1000) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVALID_GEDO_DATE',
|
||||||
|
message: 'La fecha de GEDO no puede estar más de 24 horas en el futuro',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await manager.query(`
|
||||||
|
UPDATE inspection_reports
|
||||||
|
SET status='OFFICIALIZED',
|
||||||
|
gedo_if_identifier=$2,
|
||||||
|
gedo_officialized_at=$3,
|
||||||
|
gedo_pdf_original_name=$4,
|
||||||
|
gedo_pdf_stored_name=$5,
|
||||||
|
gedo_pdf_mime_type='application/pdf',
|
||||||
|
gedo_pdf_size_bytes=$6,
|
||||||
|
gedo_pdf_sha256=$7,
|
||||||
|
updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=$1
|
||||||
|
`, [
|
||||||
|
reportId,
|
||||||
|
dto.gedoIfIdentifier,
|
||||||
|
officializedAt,
|
||||||
|
file!.originalname,
|
||||||
|
storedName,
|
||||||
|
file!.buffer.length,
|
||||||
|
sha256,
|
||||||
|
]);
|
||||||
|
// GEDO oficializa el INF, pero no equivale por sí solo a la notificación
|
||||||
|
// administrativa de un Acta no urgente. El vencimiento queda pendiente
|
||||||
|
// hasta que el procedimiento defina y registre el evento de notificación.
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_REPORT_OFFICIALIZED,
|
||||||
|
entityType: 'inspection_report',
|
||||||
|
entityId: reportId,
|
||||||
|
afterData: {
|
||||||
|
status: InspectionReportStatus.OFFICIALIZED,
|
||||||
|
gedoIfIdentifier: dto.gedoIfIdentifier,
|
||||||
|
gedoOfficializedAt: officializedAt,
|
||||||
|
gedoPdfOriginalName: file!.originalname,
|
||||||
|
gedoPdfSha256: sha256,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
reportCode: report.code,
|
||||||
|
actId: report.actId,
|
||||||
|
immutable: true,
|
||||||
|
deadlineActivation: 'PENDING_NOTIFICATION_EVENT',
|
||||||
|
},
|
||||||
|
}, manager);
|
||||||
|
return this.getWorkflowView(manager, reportId);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await unlink(filePath).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||||
|
const [row] = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
gedo_pdf_original_name AS "originalName",
|
||||||
|
gedo_pdf_stored_name AS "storedName",
|
||||||
|
gedo_pdf_mime_type AS "mimeType",
|
||||||
|
gedo_pdf_size_bytes::integer AS "sizeBytes",
|
||||||
|
gedo_pdf_sha256 AS sha256
|
||||||
|
FROM inspection_reports
|
||||||
|
WHERE id=$1
|
||||||
|
`, [reportId]) as Array<{
|
||||||
|
originalName: string | null;
|
||||||
|
storedName: string | null;
|
||||||
|
mimeType: string | null;
|
||||||
|
sizeBytes: number | null;
|
||||||
|
sha256: string | null;
|
||||||
|
}>;
|
||||||
|
if (!row) throw reportNotFound();
|
||||||
|
if (!row.originalName || !row.storedName || !row.sizeBytes || !row.sha256) {
|
||||||
|
throw new NotFoundException({
|
||||||
|
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_AVAILABLE',
|
||||||
|
message: 'El PDF oficial de GEDO todavía no está disponible',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const filePath = resolve(this.root, row.storedName);
|
||||||
|
if (filePath === this.root || !filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();
|
||||||
|
const fileStat = await stat(filePath).catch(() => null);
|
||||||
|
if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.reportStorageError();
|
||||||
|
const buffer = await readFile(filePath);
|
||||||
|
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
||||||
|
if (sha256 !== row.sha256) throw this.reportStorageError();
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
originalName: row.originalName,
|
||||||
|
mimeType: row.mimeType ?? 'application/pdf',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFollowUp(
|
||||||
|
reportId: string,
|
||||||
|
dto: CreateInspectionReportFollowUpDto,
|
||||||
|
file: UploadedInspectionReportFile | undefined,
|
||||||
|
principal: AuthPrincipal,
|
||||||
|
request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
if (file && file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INSPECTION_REPORT_FILE_TOO_LARGE',
|
||||||
|
message: 'El archivo supera el máximo permitido de 40 MB',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const followUpId = randomUUID();
|
||||||
|
const ext = this.safeExtension(file?.originalname);
|
||||||
|
const storedName = file ? `followup-${followUpId}${ext}` : null;
|
||||||
|
const filePath = storedName ? resolve(this.root, storedName) : null;
|
||||||
|
const sha256 = file ? createHash('sha256').update(file.buffer).digest('hex') : null;
|
||||||
|
if (filePath && file) {
|
||||||
|
await mkdir(this.root, { recursive: true, mode: 0o700 });
|
||||||
|
await writeFile(filePath, file.buffer, { flag: 'wx', mode: 0o600 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.dataSource.transaction(async (manager) => {
|
||||||
|
const report = await this.lockReport(manager, reportId);
|
||||||
|
const occurredAt = new Date(dto.occurredAt);
|
||||||
|
await manager.query(`
|
||||||
|
INSERT INTO inspection_report_follow_ups (
|
||||||
|
id,report_id,type,external_reference,occurred_at,description,
|
||||||
|
original_name,stored_name,mime_type,size_bytes,sha256,created_by
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
|
`, [
|
||||||
|
followUpId,
|
||||||
|
reportId,
|
||||||
|
dto.type,
|
||||||
|
dto.externalReference ?? null,
|
||||||
|
occurredAt,
|
||||||
|
dto.description ?? null,
|
||||||
|
file?.originalname ?? null,
|
||||||
|
storedName,
|
||||||
|
file?.mimetype ?? null,
|
||||||
|
file?.buffer.length ?? null,
|
||||||
|
sha256,
|
||||||
|
principal.userId,
|
||||||
|
]);
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_REPORT_FOLLOW_UP_ADDED,
|
||||||
|
entityType: 'inspection_report_follow_up',
|
||||||
|
entityId: followUpId,
|
||||||
|
afterData: {
|
||||||
|
reportId,
|
||||||
|
type: dto.type,
|
||||||
|
externalReference: dto.externalReference ?? null,
|
||||||
|
occurredAt,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
originalName: file?.originalname ?? null,
|
||||||
|
sha256,
|
||||||
|
},
|
||||||
|
metadata: { reportCode: report.code, actId: report.actId, appendOnly: true },
|
||||||
|
}, manager);
|
||||||
|
return this.listFollowUpsWithManager(manager, reportId);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (filePath) await unlink(filePath).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFollowUps(reportId: string) {
|
||||||
|
const report = await this.getReport(this.dataSource.manager, reportId);
|
||||||
|
if (!report) throw reportNotFound();
|
||||||
|
return this.listFollowUpsWithManager(this.dataSource.manager, reportId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listFollowUpsWithManager(manager: EntityManager, reportId: string) {
|
||||||
|
return manager.query(`
|
||||||
|
SELECT
|
||||||
|
follow_up.id,
|
||||||
|
follow_up.report_id AS "reportId",
|
||||||
|
follow_up.type,
|
||||||
|
follow_up.external_reference AS "externalReference",
|
||||||
|
follow_up.occurred_at AS "occurredAt",
|
||||||
|
follow_up.description,
|
||||||
|
follow_up.original_name AS "originalName",
|
||||||
|
follow_up.mime_type AS "mimeType",
|
||||||
|
follow_up.size_bytes AS "sizeBytes",
|
||||||
|
follow_up.sha256,
|
||||||
|
follow_up.created_by AS "createdBy",
|
||||||
|
follow_up.created_at AS "createdAt"
|
||||||
|
FROM inspection_report_follow_ups follow_up
|
||||||
|
WHERE follow_up.report_id=$1
|
||||||
|
ORDER BY follow_up.occurred_at,follow_up.created_at,follow_up.id
|
||||||
|
`, [reportId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportStorageError(): InternalServerErrorException {
|
||||||
|
return new InternalServerErrorException({
|
||||||
|
code: 'INSPECTION_REPORT_GEDO_PDF_STORAGE_ERROR',
|
||||||
|
message: 'El PDF oficial de GEDO no está disponible o no supera la validación de integridad',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertPdf(file: UploadedInspectionReportFile | undefined): void {
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'GEDO_PDF_REQUIRED',
|
||||||
|
message: 'Debe adjuntarse el PDF oficial generado por GEDO',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'GEDO_PDF_TOO_LARGE',
|
||||||
|
message: 'El PDF oficial supera el máximo permitido de 40 MB',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'INVALID_GEDO_PDF',
|
||||||
|
message: 'El documento oficial de GEDO debe ser un PDF válido',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeExtension(name: string | undefined): string {
|
||||||
|
if (!name) return '';
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
if (dot < 0) return '';
|
||||||
|
const ext = name.slice(dot).toLowerCase();
|
||||||
|
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
|
||||||
|
const [row] = await manager.query(`
|
||||||
|
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
|
||||||
|
executive_summary AS "executiveSummary",report_description AS "reportDescription",
|
||||||
|
gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt"
|
||||||
|
FROM inspection_reports WHERE id=$1 FOR UPDATE
|
||||||
|
`, [id]) as ReportRow[];
|
||||||
|
if (!row) throw reportNotFound();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getReport(manager: EntityManager, id: string): Promise<ReportRow | null> {
|
||||||
|
const [row] = await manager.query(`
|
||||||
|
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
|
||||||
|
executive_summary AS "executiveSummary",report_description AS "reportDescription",
|
||||||
|
gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt"
|
||||||
|
FROM inspection_reports WHERE id=$1
|
||||||
|
`, [id]) as ReportRow[];
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWorkflowView(manager: EntityManager, id: string) {
|
||||||
|
const report = await this.getReport(manager, id);
|
||||||
|
if (!report) throw reportNotFound();
|
||||||
|
const [act] = await manager.query(`
|
||||||
|
SELECT code,urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType",
|
||||||
|
deadline_basis AS "deadlineBasis",deadline_base_at AS "deadlineBaseAt",deadline_at AS "deadlineAt"
|
||||||
|
FROM inspection_acts WHERE id=$1
|
||||||
|
`, [report.actId]);
|
||||||
|
return {
|
||||||
|
...report,
|
||||||
|
act,
|
||||||
|
followUps: await this.listFollowUpsWithManager(manager, id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,41 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, Res } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
|
import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
|
||||||
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
|
||||||
|
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
|
||||||
|
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
|
||||||
|
import {
|
||||||
|
InspectionReportWorkflowService,
|
||||||
|
MAX_INSPECTION_REPORT_FILE_BYTES,
|
||||||
|
type UploadedInspectionReportFile,
|
||||||
|
} from './inspection-report-workflow.service';
|
||||||
import { InspectionReportsService } from './inspection-reports.service';
|
import { InspectionReportsService } from './inspection-reports.service';
|
||||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||||
|
|
||||||
@Controller('inspection-reports')
|
@Controller('inspection-reports')
|
||||||
export class InspectionReportsController {
|
export class InspectionReportsController {
|
||||||
constructor(private readonly reports: InspectionReportsService, private readonly word: InspectionReportWordService) {}
|
constructor(
|
||||||
|
private readonly reports: InspectionReportsService,
|
||||||
|
private readonly word: InspectionReportWordService,
|
||||||
|
private readonly workflow: InspectionReportWorkflowService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequirePermissions('inspection_reports.read')
|
@RequirePermissions('inspection_reports.read')
|
||||||
@@ -42,6 +68,65 @@ export class InspectionReportsController {
|
|||||||
return response.sendFile(content.filePath);
|
return response.sendFile(content.filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/gedo-pdf')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
async gedoPdfContent(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.workflow.officialPdfContent(id);
|
||||||
|
response.setHeader('Content-Type', content.mimeType);
|
||||||
|
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
|
||||||
|
return response.sendFile(content.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions('inspection_reports.generate')
|
||||||
|
updateNarrative(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: UpdateInspectionReportDto,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.workflow.updateNarrative(id, dto, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/officialize')
|
||||||
|
@RequirePermissions('inspection_reports.generate')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 },
|
||||||
|
}))
|
||||||
|
officialize(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: OfficializeInspectionReportDto,
|
||||||
|
@UploadedFile() file: UploadedInspectionReportFile | undefined,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.workflow.officialize(id, dto, file, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/follow-ups')
|
||||||
|
@RequirePermissions('inspection_reports.read')
|
||||||
|
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
return this.workflow.listFollowUps(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/follow-ups')
|
||||||
|
@RequirePermissions('inspection_reports.generate')
|
||||||
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
|
limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 },
|
||||||
|
}))
|
||||||
|
addFollowUp(
|
||||||
|
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||||
|
@Body() dto: CreateInspectionReportFollowUpDto,
|
||||||
|
@UploadedFile() file: UploadedInspectionReportFile | undefined,
|
||||||
|
@CurrentAuth() principal: AuthPrincipal,
|
||||||
|
@Req() request: RequestWithContext,
|
||||||
|
) {
|
||||||
|
return this.workflow.addFollowUp(id, dto, file, principal, request);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequirePermissions('inspection_reports.read')
|
@RequirePermissions('inspection_reports.read')
|
||||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
import { DocumentDeliveryController } from './document-delivery.controller';
|
import { DocumentDeliveryController } from './document-delivery.controller';
|
||||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||||
|
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
|
||||||
|
import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service';
|
||||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||||
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
|
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
|
||||||
import { InspectionReportReviewService } from './inspection-report-review.service';
|
|
||||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||||
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||||
import { InspectionReportsService } from './inspection-reports.service';
|
import { InspectionReportsService } from './inspection-reports.service';
|
||||||
@@ -15,18 +16,18 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
|
|||||||
controllers: [
|
controllers: [
|
||||||
InspectionReportsController,
|
InspectionReportsController,
|
||||||
InspectionActReportController,
|
InspectionActReportController,
|
||||||
|
InspectionDeadlineAdminController,
|
||||||
DocumentDeliveryController,
|
DocumentDeliveryController,
|
||||||
InspectionReportReviewController,
|
|
||||||
InspectionReportRevisionContentController,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
InspectionReportsService,
|
InspectionReportsService,
|
||||||
|
InspectionReportWorkflowService,
|
||||||
InspectionReportWordService,
|
InspectionReportWordService,
|
||||||
InspectionActPdfService,
|
InspectionActPdfService,
|
||||||
|
InspectionDeadlineAdminService,
|
||||||
InspectionDocumentDeliveryService,
|
InspectionDocumentDeliveryService,
|
||||||
SmtpDeliveryService,
|
SmtpDeliveryService,
|
||||||
InspectionReportReviewService,
|
|
||||||
],
|
],
|
||||||
exports: [InspectionReportsService],
|
exports: [InspectionReportsService, SmtpDeliveryService],
|
||||||
})
|
})
|
||||||
export class InspectionReportsModule {}
|
export class InspectionReportsModule {}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { AuditService } from '../audit/audit.service';
|
|||||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||||
import {
|
import {
|
||||||
AuditAction,
|
AuditAction,
|
||||||
DocumentSequenceType,
|
|
||||||
InspectionActStatus,
|
InspectionActStatus,
|
||||||
InspectionReportPdfStatus,
|
InspectionReportPdfStatus,
|
||||||
InspectionReportStatus,
|
InspectionReportStatus,
|
||||||
@@ -16,7 +15,7 @@ import type { ListInspectionReportsQueryDto } from './dto/list-inspection-report
|
|||||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||||
|
|
||||||
const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1';
|
const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-INF-V4';
|
||||||
|
|
||||||
interface ContextAsset {
|
interface ContextAsset {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,21 +31,32 @@ export interface InspectionReportListItem {
|
|||||||
reportNumber: number;
|
reportNumber: number;
|
||||||
code: string;
|
code: string;
|
||||||
status: InspectionReportStatus;
|
status: InspectionReportStatus;
|
||||||
|
executiveSummary: string | null;
|
||||||
|
reportDescription: string | null;
|
||||||
|
gedoIfIdentifier: string | null;
|
||||||
|
gedoOfficializedAt: Date | null;
|
||||||
|
gedoPdfOriginalName: string | null;
|
||||||
|
gedoPdfSha256: string | null;
|
||||||
pdfStatus: InspectionReportPdfStatus;
|
pdfStatus: InspectionReportPdfStatus;
|
||||||
wordStatus: 'PENDING' | 'READY' | 'FAILED';
|
wordStatus: 'PENDING' | 'READY' | 'FAILED';
|
||||||
wordGeneratedAt: Date | null;
|
wordGeneratedAt: Date | null;
|
||||||
reviewStatus: 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
|
|
||||||
currentRevisionNumber: number;
|
|
||||||
approvedAt: Date | null;
|
|
||||||
signedAt: Date | null;
|
|
||||||
title: string;
|
title: string;
|
||||||
actVersion: number;
|
actVersion: number;
|
||||||
actClosureSha256: string;
|
actClosureSha256: string;
|
||||||
frozenSha256: string;
|
frozenSha256: string;
|
||||||
generatedAt: Date;
|
generatedAt: Date;
|
||||||
generatedBy: { id: string; username: string; firstName: string; lastName: string };
|
generatedBy: { id: string; username: string; firstName: string; lastName: string };
|
||||||
act: { id: string; code: string; title: string; status: string; occurredAt: Date; closedAt: Date | null };
|
act: {
|
||||||
visit: { id: string; code: string; title: string; status: string };
|
id: string;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
occurredAt: Date;
|
||||||
|
sealedAt: Date | null;
|
||||||
|
urgency: string;
|
||||||
|
deadlineAt: Date | null;
|
||||||
|
};
|
||||||
|
visit: { id: string; code: string; status: string };
|
||||||
companies: ContextAsset[];
|
companies: ContextAsset[];
|
||||||
areas: ContextAsset[];
|
areas: ContextAsset[];
|
||||||
findingCount: number;
|
findingCount: number;
|
||||||
@@ -63,10 +73,9 @@ export interface PendingInspectionReportItem {
|
|||||||
actTitle: string;
|
actTitle: string;
|
||||||
actYear: number;
|
actYear: number;
|
||||||
occurredAt: Date;
|
occurredAt: Date;
|
||||||
closedAt: Date;
|
sealedAt: Date;
|
||||||
closureSha256: string;
|
closureSha256: string;
|
||||||
visitCode: string;
|
visitCode: string;
|
||||||
visitTitle: string;
|
|
||||||
companies: ContextAsset[];
|
companies: ContextAsset[];
|
||||||
areas: ContextAsset[];
|
areas: ContextAsset[];
|
||||||
findingCount: number;
|
findingCount: number;
|
||||||
@@ -127,7 +136,7 @@ export class InspectionReportsService {
|
|||||||
|
|
||||||
async listPending(query: ListInspectionReportsQueryDto) {
|
async listPending(query: ListInspectionReportsQueryDto) {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
`act.status = 'CLOSED'`,
|
`act.status = 'SEALED'`,
|
||||||
'report.id IS NULL',
|
'report.id IS NULL',
|
||||||
'act.closure_sha256 IS NOT NULL',
|
'act.closure_sha256 IS NOT NULL',
|
||||||
];
|
];
|
||||||
@@ -142,22 +151,6 @@ export class InspectionReportsService {
|
|||||||
act.code ILIKE ${search}
|
act.code ILIKE ${search}
|
||||||
OR act.title ILIKE ${search}
|
OR act.title ILIKE ${search}
|
||||||
OR visit.code ILIKE ${search}
|
OR visit.code ILIKE ${search}
|
||||||
OR visit.title ILIKE ${search}
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM inspection_act_assets search_link
|
|
||||||
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
|
|
||||||
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
|
|
||||||
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
|
|
||||||
WHERE search_link.act_id = act.id
|
|
||||||
AND search_link.included = true
|
|
||||||
AND (
|
|
||||||
search_asset.name ILIKE ${search}
|
|
||||||
OR search_asset.code ILIKE ${search}
|
|
||||||
OR search_company.name ILIKE ${search}
|
|
||||||
OR search_area.name ILIKE ${search}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)`);
|
)`);
|
||||||
}
|
}
|
||||||
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
|
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
|
||||||
@@ -196,10 +189,9 @@ export class InspectionReportsService {
|
|||||||
act.title AS "actTitle",
|
act.title AS "actTitle",
|
||||||
act.act_year AS "actYear",
|
act.act_year AS "actYear",
|
||||||
act.occurred_at AS "occurredAt",
|
act.occurred_at AS "occurredAt",
|
||||||
act.closed_at AS "closedAt",
|
act.sealed_at AS "sealedAt",
|
||||||
act.closure_sha256 AS "closureSha256",
|
act.closure_sha256 AS "closureSha256",
|
||||||
visit.code AS "visitCode",
|
visit.code AS "visitCode",
|
||||||
visit.title AS "visitTitle",
|
|
||||||
COALESCE(context.companies, '[]'::jsonb) AS companies,
|
COALESCE(context.companies, '[]'::jsonb) AS companies,
|
||||||
COALESCE(context.areas, '[]'::jsonb) AS areas,
|
COALESCE(context.areas, '[]'::jsonb) AS areas,
|
||||||
COALESCE(finding_count.total, 0)::integer AS "findingCount"
|
COALESCE(finding_count.total, 0)::integer AS "findingCount"
|
||||||
@@ -226,16 +218,7 @@ export class InspectionReportsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string): Promise<InspectionReportView> {
|
async get(id: string): Promise<InspectionReportView> {
|
||||||
const [report] = (await this.dataSource.query(
|
return this.getWithManager(this.dataSource.manager, id);
|
||||||
`${this.reportSelect('WHERE report.id = $1')}`,
|
|
||||||
[id],
|
|
||||||
)) as InspectionReportListItem[];
|
|
||||||
if (!report) throw reportNotFound();
|
|
||||||
const [snapshot] = (await this.dataSource.query(
|
|
||||||
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
|
|
||||||
[id],
|
|
||||||
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
|
|
||||||
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async generate(
|
async generate(
|
||||||
@@ -244,144 +227,144 @@ export class InspectionReportsService {
|
|||||||
request: RequestWithContext,
|
request: RequestWithContext,
|
||||||
): Promise<InspectionReportView> {
|
): Promise<InspectionReportView> {
|
||||||
assertMobileInspector(principal);
|
assertMobileInspector(principal);
|
||||||
const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request));
|
const report = await this.dataSource.transaction((manager) =>
|
||||||
|
this.ensureFrozenReport(manager, actId, principal, request),
|
||||||
|
);
|
||||||
await this.word.ensure(report.id);
|
await this.word.ensure(report.id);
|
||||||
return this.get(report.id);
|
return this.get(report.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conserva una copia interna inmutable del Acta sellada como fuente documental.
|
||||||
|
* El INF que se genera desde esa fuente permanece editable y versionable.
|
||||||
|
*/
|
||||||
async ensureFrozenReport(
|
async ensureFrozenReport(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
actId: string,
|
actId: string,
|
||||||
principal: AuthPrincipal,
|
principal: AuthPrincipal,
|
||||||
request: RequestWithContext,
|
request: RequestWithContext,
|
||||||
): Promise<InspectionReportView> {
|
): Promise<InspectionReportView> {
|
||||||
const [act] = (await manager.query(`
|
const [act] = (await manager.query(`
|
||||||
SELECT
|
SELECT
|
||||||
act.id,
|
|
||||||
act.visit_id AS "visitId",
|
|
||||||
act.act_year AS "actYear",
|
|
||||||
act.code,
|
|
||||||
act.title,
|
|
||||||
act.status,
|
|
||||||
act.current_version AS "currentVersion",
|
|
||||||
act.closure_sha256 AS "closureSha256",
|
|
||||||
visit.code AS "visitCode",
|
|
||||||
visit.title AS "visitTitle"
|
|
||||||
FROM inspection_acts act
|
|
||||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
|
||||||
WHERE act.id = $1
|
|
||||||
FOR UPDATE OF act
|
|
||||||
`, [actId])) as Array<{
|
|
||||||
id: string;
|
|
||||||
visitId: string;
|
|
||||||
actYear: number;
|
|
||||||
code: string;
|
|
||||||
title: string;
|
|
||||||
status: InspectionActStatus;
|
|
||||||
currentVersion: number;
|
|
||||||
closureSha256: string | null;
|
|
||||||
visitCode: string;
|
|
||||||
visitTitle: string;
|
|
||||||
}>;
|
|
||||||
if (!act) throw actNotFound();
|
|
||||||
if (act.status !== InspectionActStatus.CLOSED || !act.closureSha256) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'INSPECTION_REPORT_ACT_NOT_CLOSED',
|
|
||||||
message: 'El informe sólo puede emitirse después del cierre definitivo del acta',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await this.assertActorAssigned(manager, act.visitId, principal.userId);
|
|
||||||
const [existing] = (await manager.query(
|
|
||||||
'SELECT id FROM inspection_reports WHERE act_id = $1',
|
|
||||||
[actId],
|
|
||||||
)) as Array<{ id: string }>;
|
|
||||||
if (existing) return this.getWithManager(manager, existing.id);
|
|
||||||
const [closure] = (await manager.query(`
|
|
||||||
SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256"
|
|
||||||
FROM inspection_act_closures
|
|
||||||
WHERE act_id = $1
|
|
||||||
`, [actId])) as Array<{
|
|
||||||
finalSnapshot: Record<string, unknown> | null;
|
|
||||||
finalSha256: string | null;
|
|
||||||
}>;
|
|
||||||
if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'INSPECTION_REPORT_CLOSURE_NOT_FROZEN',
|
|
||||||
message: 'El cierre del acta no tiene una instantánea final válida para emitir el informe',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const reportNumber = await this.allocateNumber(manager, act.actYear);
|
|
||||||
if (reportNumber > 999999) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code: 'INSPECTION_REPORT_SEQUENCE_EXHAUSTED',
|
|
||||||
message: 'La numeración anual de informes agotó su rango disponible',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const code = `INF-${act.actYear}-${String(reportNumber).padStart(6, '0')}`;
|
|
||||||
const generatedAt = new Date();
|
|
||||||
const title = `Informe técnico · ${act.title}`.slice(0, 220);
|
|
||||||
const frozenSnapshot = {
|
|
||||||
schemaVersion: REPORT_SCHEMA_VERSION,
|
|
||||||
report: {
|
|
||||||
code,
|
|
||||||
reportYear: act.actYear,
|
|
||||||
reportNumber,
|
|
||||||
generatedAt: generatedAt.toISOString(),
|
|
||||||
generatedBy: principal.userId,
|
|
||||||
generatedByUsername: principal.username,
|
|
||||||
visitId: act.visitId,
|
|
||||||
visitCode: act.visitCode,
|
|
||||||
visitTitle: act.visitTitle,
|
|
||||||
actId: act.id,
|
|
||||||
actCode: act.code,
|
|
||||||
actVersion: act.currentVersion,
|
|
||||||
actClosureSha256: act.closureSha256,
|
|
||||||
},
|
|
||||||
actClosure: closure.finalSnapshot,
|
|
||||||
};
|
|
||||||
const frozenSha256 = sha256CanonicalJson(frozenSnapshot);
|
|
||||||
const [created] = (await manager.query(`
|
|
||||||
INSERT INTO inspection_reports (
|
|
||||||
visit_id, act_id, report_year, report_number, code, status, pdf_status,
|
|
||||||
title, act_version, act_closure_sha256, frozen_sha256, frozen_snapshot,
|
|
||||||
generated_at, generated_by
|
|
||||||
) VALUES (
|
|
||||||
$1, $2, $3, $4, $5, 'FROZEN', 'PENDING', $6, $7, $8, $9, $10, $11, $12
|
|
||||||
)
|
|
||||||
RETURNING id
|
|
||||||
`, [
|
|
||||||
act.visitId,
|
|
||||||
act.id,
|
act.id,
|
||||||
act.actYear,
|
act.visit_id AS "visitId",
|
||||||
reportNumber,
|
act.act_year AS "actYear",
|
||||||
|
act.act_number AS "actNumber",
|
||||||
|
act.code,
|
||||||
|
act.title,
|
||||||
|
act.status,
|
||||||
|
act.current_version AS "currentVersion",
|
||||||
|
act.closure_sha256 AS "closureSha256",
|
||||||
|
act.occurred_at AS "occurredAt",
|
||||||
|
visit.code AS "visitCode"
|
||||||
|
FROM inspection_acts act
|
||||||
|
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||||
|
WHERE act.id = $1
|
||||||
|
FOR UPDATE OF act
|
||||||
|
`, [actId])) as Array<{
|
||||||
|
id: string;
|
||||||
|
visitId: string;
|
||||||
|
actYear: number;
|
||||||
|
actNumber: number;
|
||||||
|
code: string;
|
||||||
|
title: string;
|
||||||
|
status: InspectionActStatus;
|
||||||
|
currentVersion: number;
|
||||||
|
closureSha256: string | null;
|
||||||
|
occurredAt: Date;
|
||||||
|
visitCode: string;
|
||||||
|
}>;
|
||||||
|
if (!act) throw actNotFound();
|
||||||
|
if (act.status !== InspectionActStatus.SEALED || !act.closureSha256) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_ACT_NOT_SEALED',
|
||||||
|
message: 'El INF sólo puede generarse después de sellar el acta',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.assertActorAssigned(manager, act.visitId, principal.userId);
|
||||||
|
const [existing] = (await manager.query(
|
||||||
|
'SELECT id FROM inspection_reports WHERE act_id = $1',
|
||||||
|
[actId],
|
||||||
|
)) as Array<{ id: string }>;
|
||||||
|
if (existing) return this.getWithManager(manager, existing.id);
|
||||||
|
|
||||||
|
const [closure] = (await manager.query(`
|
||||||
|
SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256"
|
||||||
|
FROM inspection_act_closures
|
||||||
|
WHERE act_id = $1
|
||||||
|
`, [actId])) as Array<{
|
||||||
|
finalSnapshot: Record<string, unknown> | null;
|
||||||
|
finalSha256: string | null;
|
||||||
|
}>;
|
||||||
|
if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) {
|
||||||
|
throw new ConflictException({
|
||||||
|
code: 'INSPECTION_REPORT_ACT_SNAPSHOT_INVALID',
|
||||||
|
message: 'El acta sellada no tiene una instantánea final válida',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportNumber = act.actNumber;
|
||||||
|
const code = act.code.replace(/^ACT-/, 'INF-');
|
||||||
|
const generatedAt = new Date();
|
||||||
|
const title = `Informe de inspección · ${act.code}`.slice(0, 220);
|
||||||
|
const frozenSnapshot = {
|
||||||
|
schemaVersion: REPORT_SCHEMA_VERSION,
|
||||||
|
source: {
|
||||||
|
actId: act.id,
|
||||||
|
actCode: act.code,
|
||||||
|
actVersion: act.currentVersion,
|
||||||
|
actClosureSha256: act.closureSha256,
|
||||||
|
inspectionId: act.visitId,
|
||||||
|
inspectionCode: act.visitCode,
|
||||||
|
},
|
||||||
|
sealedAct: closure.finalSnapshot,
|
||||||
|
};
|
||||||
|
const frozenSha256 = sha256CanonicalJson(frozenSnapshot);
|
||||||
|
const [created] = (await manager.query(`
|
||||||
|
INSERT INTO inspection_reports (
|
||||||
|
visit_id,act_id,report_year,report_number,code,status,pdf_status,
|
||||||
|
executive_summary,report_description,title,act_version,act_closure_sha256,
|
||||||
|
frozen_sha256,frozen_snapshot,generated_at,generated_by
|
||||||
|
) VALUES (
|
||||||
|
$1,$2,$3,$4,$5,'WORKING','PENDING',NULL,NULL,$6,$7,$8,$9,$10,$11,$12
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`, [
|
||||||
|
act.visitId,
|
||||||
|
act.id,
|
||||||
|
act.actYear,
|
||||||
|
reportNumber,
|
||||||
|
code,
|
||||||
|
title,
|
||||||
|
act.currentVersion,
|
||||||
|
act.closureSha256,
|
||||||
|
frozenSha256,
|
||||||
|
frozenSnapshot,
|
||||||
|
generatedAt,
|
||||||
|
principal.userId,
|
||||||
|
])) as Array<{ id: string }>;
|
||||||
|
await this.audit.record({
|
||||||
|
...administrationAuditContext(principal, request),
|
||||||
|
action: AuditAction.INSPECTION_REPORT_GENERATED,
|
||||||
|
entityType: 'inspection_report',
|
||||||
|
entityId: created.id,
|
||||||
|
afterData: {
|
||||||
code,
|
code,
|
||||||
title,
|
actId: act.id,
|
||||||
act.currentVersion,
|
inspectionId: act.visitId,
|
||||||
act.closureSha256,
|
actVersion: act.currentVersion,
|
||||||
frozenSha256,
|
frozenSha256,
|
||||||
frozenSnapshot,
|
status: InspectionReportStatus.WORKING,
|
||||||
generatedAt,
|
},
|
||||||
principal.userId,
|
}, manager);
|
||||||
])) as Array<{ id: string }>;
|
return this.getWithManager(manager, created.id);
|
||||||
await this.audit.record({
|
|
||||||
...administrationAuditContext(principal, request),
|
|
||||||
action: AuditAction.INSPECTION_REPORT_GENERATED,
|
|
||||||
entityType: 'inspection_report',
|
|
||||||
entityId: created.id,
|
|
||||||
afterData: {
|
|
||||||
code,
|
|
||||||
actId: act.id,
|
|
||||||
visitId: act.visitId,
|
|
||||||
actVersion: act.currentVersion,
|
|
||||||
frozenSha256,
|
|
||||||
pdfStatus: InspectionReportPdfStatus.PENDING,
|
|
||||||
},
|
|
||||||
}, manager);
|
|
||||||
return this.getWithManager(manager, created.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureWordForAct(actId: string): Promise<void> {
|
async ensureWordForAct(actId: string): Promise<void> {
|
||||||
const [row] = await this.dataSource.query('SELECT id FROM inspection_reports WHERE act_id = $1', [actId]) as Array<{ id: string }>;
|
const [row] = await this.dataSource.query(
|
||||||
|
'SELECT id FROM inspection_reports WHERE act_id = $1',
|
||||||
|
[actId],
|
||||||
|
) as Array<{ id: string }>;
|
||||||
if (row) await this.word.ensure(row.id);
|
if (row) await this.word.ensure(row.id);
|
||||||
await this.delivery.dispatchForAct(actId).catch(() => undefined);
|
await this.delivery.dispatchForAct(actId).catch(() => undefined);
|
||||||
}
|
}
|
||||||
@@ -398,25 +381,10 @@ export class InspectionReportsService {
|
|||||||
conditions.push(`(
|
conditions.push(`(
|
||||||
report.code ILIKE ${search}
|
report.code ILIKE ${search}
|
||||||
OR report.title ILIKE ${search}
|
OR report.title ILIKE ${search}
|
||||||
|
OR report.gedo_if_identifier ILIKE ${search}
|
||||||
OR act.code ILIKE ${search}
|
OR act.code ILIKE ${search}
|
||||||
OR act.title ILIKE ${search}
|
OR act.title ILIKE ${search}
|
||||||
OR visit.code ILIKE ${search}
|
OR visit.code ILIKE ${search}
|
||||||
OR visit.title ILIKE ${search}
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM inspection_act_assets search_link
|
|
||||||
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
|
|
||||||
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
|
|
||||||
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
|
|
||||||
WHERE search_link.act_id = act.id
|
|
||||||
AND search_link.included = true
|
|
||||||
AND (
|
|
||||||
search_asset.name ILIKE ${search}
|
|
||||||
OR search_asset.code ILIKE ${search}
|
|
||||||
OR search_company.name ILIKE ${search}
|
|
||||||
OR search_area.name ILIKE ${search}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)`);
|
)`);
|
||||||
}
|
}
|
||||||
if (query.year) conditions.push(`report.report_year = ${add(query.year)}`);
|
if (query.year) conditions.push(`report.report_year = ${add(query.year)}`);
|
||||||
@@ -454,20 +422,16 @@ export class InspectionReportsService {
|
|||||||
return `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
||||||
'id', company.id,
|
'id',company.id,'code',company.code,'name',company.name
|
||||||
'code', company.code,
|
)) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies,
|
||||||
'name', company.name
|
|
||||||
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
|
|
||||||
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
||||||
'id', area.id,
|
'id',area.id,'code',area.code,'name',area.name
|
||||||
'code', area.code,
|
)) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas
|
||||||
'name', area.name
|
|
||||||
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
|
|
||||||
FROM inspection_act_assets context_link
|
FROM inspection_act_assets context_link
|
||||||
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
|
INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id
|
||||||
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
|
LEFT JOIN assets company ON company.id=context_asset.operator_company_id
|
||||||
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
|
LEFT JOIN assets area ON area.id=context_asset.operational_area_id
|
||||||
WHERE context_link.act_id = act.id AND context_link.included = true
|
WHERE context_link.act_id=act.id AND context_link.included=true
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,50 +445,43 @@ export class InspectionReportsService {
|
|||||||
report.report_number AS "reportNumber",
|
report.report_number AS "reportNumber",
|
||||||
report.code,
|
report.code,
|
||||||
report.status,
|
report.status,
|
||||||
|
report.executive_summary AS "executiveSummary",
|
||||||
|
report.report_description AS "reportDescription",
|
||||||
|
report.gedo_if_identifier AS "gedoIfIdentifier",
|
||||||
|
report.gedo_officialized_at AS "gedoOfficializedAt",
|
||||||
|
report.gedo_pdf_original_name AS "gedoPdfOriginalName",
|
||||||
|
report.gedo_pdf_sha256 AS "gedoPdfSha256",
|
||||||
report.pdf_status AS "pdfStatus",
|
report.pdf_status AS "pdfStatus",
|
||||||
report.word_status AS "wordStatus",
|
report.word_status AS "wordStatus",
|
||||||
report.word_generated_at AS "wordGeneratedAt",
|
report.word_generated_at AS "wordGeneratedAt",
|
||||||
report.review_status AS "reviewStatus",
|
|
||||||
report.current_revision_number AS "currentRevisionNumber",
|
|
||||||
report.approved_at AS "approvedAt",
|
|
||||||
report.signed_at AS "signedAt",
|
|
||||||
report.title,
|
report.title,
|
||||||
report.act_version AS "actVersion",
|
report.act_version AS "actVersion",
|
||||||
report.act_closure_sha256 AS "actClosureSha256",
|
report.act_closure_sha256 AS "actClosureSha256",
|
||||||
report.frozen_sha256 AS "frozenSha256",
|
report.frozen_sha256 AS "frozenSha256",
|
||||||
report.generated_at AS "generatedAt",
|
report.generated_at AS "generatedAt",
|
||||||
JSONB_BUILD_OBJECT(
|
JSONB_BUILD_OBJECT(
|
||||||
'id', generator.id,
|
'id',generator.id,'username',generator.username,
|
||||||
'username', generator.username,
|
'firstName',generator.first_name,'lastName',generator.last_name
|
||||||
'firstName', generator.first_name,
|
|
||||||
'lastName', generator.last_name
|
|
||||||
) AS "generatedBy",
|
) AS "generatedBy",
|
||||||
JSONB_BUILD_OBJECT(
|
JSONB_BUILD_OBJECT(
|
||||||
'id', act.id,
|
'id',act.id,'code',act.code,'title',act.title,'status',act.status,
|
||||||
'code', act.code,
|
'occurredAt',act.occurred_at,'sealedAt',act.sealed_at,
|
||||||
'title', act.title,
|
'urgency',act.urgency,'deadlineAt',act.deadline_at
|
||||||
'status', act.status,
|
|
||||||
'occurredAt', act.occurred_at,
|
|
||||||
'closedAt', act.closed_at
|
|
||||||
) AS act,
|
) AS act,
|
||||||
JSONB_BUILD_OBJECT(
|
JSONB_BUILD_OBJECT(
|
||||||
'id', visit.id,
|
'id',visit.id,'code',visit.code,'status',visit.status
|
||||||
'code', visit.code,
|
|
||||||
'title', visit.title,
|
|
||||||
'status', visit.status
|
|
||||||
) AS visit,
|
) AS visit,
|
||||||
COALESCE(context.companies, '[]'::jsonb) AS companies,
|
COALESCE(context.companies,'[]'::jsonb) AS companies,
|
||||||
COALESCE(context.areas, '[]'::jsonb) AS areas,
|
COALESCE(context.areas,'[]'::jsonb) AS areas,
|
||||||
COALESCE(finding_count.total, 0)::integer AS "findingCount"
|
COALESCE(finding_count.total,0)::integer AS "findingCount"
|
||||||
FROM inspection_reports report
|
FROM inspection_reports report
|
||||||
INNER JOIN inspection_acts act ON act.id = report.act_id
|
INNER JOIN inspection_acts act ON act.id=report.act_id
|
||||||
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
|
INNER JOIN inspection_visits visit ON visit.id=report.visit_id
|
||||||
INNER JOIN users generator ON generator.id = report.generated_by
|
INNER JOIN users generator ON generator.id=report.generated_by
|
||||||
LEFT JOIN LATERAL (${this.contextSelect()}) context ON true
|
LEFT JOIN LATERAL (${this.contextSelect()}) context ON true
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total FROM inspection_findings finding
|
||||||
FROM inspection_findings finding
|
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
|
||||||
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
|
|
||||||
) finding_count ON true
|
) finding_count ON true
|
||||||
${where}
|
${where}
|
||||||
`;
|
`;
|
||||||
@@ -543,29 +500,15 @@ export class InspectionReportsService {
|
|||||||
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
|
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async allocateNumber(manager: EntityManager, year: number): Promise<number> {
|
|
||||||
const [row] = (await manager.query(`
|
|
||||||
INSERT INTO document_annual_sequences (document_type, year, last_number)
|
|
||||||
VALUES ($1, $2, 1)
|
|
||||||
ON CONFLICT (document_type, year) DO UPDATE SET
|
|
||||||
last_number = document_annual_sequences.last_number + 1,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
RETURNING last_number AS number
|
|
||||||
`, [DocumentSequenceType.REPORT, year])) as Array<{ number: number }>;
|
|
||||||
return Number(row.number);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
|
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
|
||||||
const rows = (await manager.query(`
|
const rows = (await manager.query(`
|
||||||
SELECT 1
|
SELECT 1 FROM inspection_visit_members
|
||||||
FROM inspection_visit_members
|
WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1
|
||||||
WHERE visit_id = $1 AND user_id = $2 AND included = true
|
|
||||||
LIMIT 1
|
|
||||||
`, [visitId, userId])) as unknown[];
|
`, [visitId, userId])) as unknown[];
|
||||||
if (rows.length === 0) {
|
if (!rows.length) {
|
||||||
throw new ForbiddenException({
|
throw new ForbiddenException({
|
||||||
code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED',
|
code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED',
|
||||||
message: 'Sólo un inspector asignado a la visita puede solicitar el informe',
|
message: 'Sólo un inspector asignado a la inspección puede solicitar el informe',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user