Compare commits
259
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8800f06ad1 | ||
|
|
a22f4999c4 | ||
|
|
9bd6c9cfd3 | ||
|
|
3297dfca45 | ||
|
|
311ff011c5 | ||
|
|
bdf2815908 | ||
|
|
2c615e405d | ||
|
|
1e486a80ea | ||
|
|
fd3f5d5c84 | ||
|
|
d89ad8ca51 | ||
|
|
8f14b7dd6f | ||
|
|
f4ba55c146 | ||
|
|
80a65c9b7d | ||
|
|
2ce6b16a96 | ||
|
|
0f56e57d62 | ||
|
|
69f377f522 | ||
|
|
a96ca279a7 | ||
|
|
6c958d4d21 | ||
|
|
3f3764c54b | ||
|
|
40da9dcc0a | ||
|
|
7eaa05544e | ||
|
|
4db6abb3c5 | ||
|
|
d7557ef600 | ||
|
|
0b0484c5fe | ||
|
|
6d2728a234 | ||
|
|
3d83545033 | ||
|
|
2b87e07fd2 | ||
|
|
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
|
||||
CSRF_COOKIE_NAME=dhv2_csrf
|
||||
|
||||
INSPECTION_SIGNATURE_ROOT=/app/storage/asset-media/inspection-signatures
|
||||
INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
|
||||
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
|
||||
|
||||
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf
|
||||
|
||||
# Enlace público de un solo uso para firma/manifestación de empresa.
|
||||
# En producción: https://dhv2.korexlabs.com/firma-acta
|
||||
# En entorno de prueba: https://prueba.dhv2.korexlabs.com/firma-acta
|
||||
COMPANY_SIGNATURE_PUBLIC_BASE_URL=https://dhv2.korexlabs.com/firma-acta
|
||||
|
||||
# Clave maestra de 32 bytes (64 hex o base64) para cifrar la contraseña SMTP guardada por Superadmin.
|
||||
SMTP_SETTINGS_MASTER_KEY=CHANGE_ME_WITH_32_RANDOM_BYTES
|
||||
|
||||
# Fallback SMTP. Se usa sólo hasta que Superadmin guarde una configuración en la base de datos.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/f5-android-test'
|
||||
- 'feature/f2-2*'
|
||||
- 'feature/f2-3*'
|
||||
- 'feature/f2-4*'
|
||||
@@ -16,6 +18,8 @@ on:
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- 'api-v3/src/auth/**'
|
||||
- 'api-v3/src/inspection-visits/**'
|
||||
- 'api-v3/src/inspection-acts/**'
|
||||
- '.github/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -58,7 +62,7 @@ jobs:
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
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
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
retention-days: 30
|
||||
|
||||
@@ -62,8 +62,160 @@ jobs:
|
||||
while IFS= read -r -d '' script; do
|
||||
bash -n "$script"
|
||||
done < <(find scripts -type f -name '*.sh' -print0)
|
||||
- name: Validate deploy preflight parity
|
||||
run: |
|
||||
grep -Fq -- '$STAGE/docker-compose.yml:/docker-compose.yml:ro' scripts/deploy-github.sh
|
||||
grep -Fq -- '$STAGE/web-v2:/web-v2:ro' scripts/deploy-github.sh
|
||||
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
|
||||
- name: Validate Compose
|
||||
run: docker compose --env-file .env.example config >/dev/null
|
||||
- name: Rehearse migrations on clean PostGIS
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cleanup() {
|
||||
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
|
||||
docker compose --env-file .env.example up -d db
|
||||
|
||||
# Historical production reset is a one-shot migration that expects the
|
||||
# production admin. Prove the clean chain reaches that exact guard,
|
||||
# mark only that historical reset as applied, then continue the chain.
|
||||
bootstrap_log="$(mktemp)"
|
||||
set +e
|
||||
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
|
||||
bootstrap_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$bootstrap_status" -eq 0 ]; then
|
||||
echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
|
||||
echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2
|
||||
exit 1
|
||||
}
|
||||
rm -f "$bootstrap_log"
|
||||
|
||||
docker compose --env-file .env.example exec -T db \
|
||||
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||
DO $$
|
||||
DECLARE reset_rows integer;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO reset_rows
|
||||
FROM typeorm_migrations
|
||||
WHERE name = 'ResetProductionOperationalData1788652800000';
|
||||
IF reset_rows <> 0 THEN
|
||||
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
|
||||
END IF;
|
||||
INSERT INTO typeorm_migrations ("timestamp", name)
|
||||
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
docker compose --env-file .env.example --profile tools run --rm migrate
|
||||
|
||||
# F5.1 intentionally ends with zero operational/domain instances. The
|
||||
# technical family and finding masters remain, but territory preload,
|
||||
# imports, applicability links and old audits are deliberately gone.
|
||||
docker compose --env-file .env.example exec -T db \
|
||||
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||
DO $$
|
||||
DECLARE
|
||||
f5_migrations integer;
|
||||
domain_assets integer;
|
||||
audits integer;
|
||||
applicability integer;
|
||||
territory_sources integer;
|
||||
source_installations integer;
|
||||
source_subinstallations integer;
|
||||
source_findings integer;
|
||||
department_types integer;
|
||||
area_department_rules integer;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO f5_migrations
|
||||
FROM typeorm_migrations
|
||||
WHERE name IN (
|
||||
'F5InventoryPhysicalInstance1790087100000',
|
||||
'F5CanonicalInventoryHierarchy1790087150000',
|
||||
'F5AuthoritativeTerritory1790087200000',
|
||||
'F5OperationalContextCompatibility1790087250000',
|
||||
'F5AuthoritativeInventoryCatalog1790087300000',
|
||||
'F51CleanManualInventory1790087400000'
|
||||
);
|
||||
IF f5_migrations <> 6 THEN
|
||||
RAISE EXCEPTION 'Expected 6 F5/F5.1 migrations, got %', f5_migrations;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO domain_assets FROM assets;
|
||||
IF domain_assets <> 0 THEN
|
||||
RAISE EXCEPTION 'F5.1 clean start must contain 0 Assets, got %', domain_assets;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO audits FROM audit_events;
|
||||
IF audits <> 0 THEN
|
||||
RAISE EXCEPTION 'F5.1 clean start must contain 0 audit events, got %', audits;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
|
||||
IF applicability <> 0 THEN
|
||||
RAISE EXCEPTION 'F5.1 clean start must contain 0 finding applicability links, got %', applicability;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO territory_sources
|
||||
FROM source_documents
|
||||
WHERE document_number='DH-F5-TERRITORY';
|
||||
IF territory_sources <> 0 THEN
|
||||
RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
|
||||
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
|
||||
INTO source_installations,source_subinstallations
|
||||
FROM inventory_families
|
||||
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
|
||||
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
|
||||
RAISE EXCEPTION 'F5.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations;
|
||||
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.1 must preserve finding master catalog, got %', source_findings;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO department_types
|
||||
FROM asset_types
|
||||
WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true;
|
||||
IF department_types <> 1 THEN
|
||||
RAISE EXCEPTION 'Expected one active root Departamento type, got %', department_types;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO area_department_rules
|
||||
FROM asset_type_parent_rules rule
|
||||
JOIN asset_types child ON child.id=rule.child_type_id
|
||||
JOIN asset_types parent ON parent.id=rule.parent_type_id
|
||||
WHERE lower(child.code)='area' AND lower(parent.code)='departamento';
|
||||
IF area_department_rules <> 1 THEN
|
||||
RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
# F5.1 is intentionally one-way: production rollback is the PRE database
|
||||
# backup, not migration:revert. Prove instead that the completed chain is
|
||||
# idempotent and has no pending migration on a second run.
|
||||
rerun_log="$(mktemp)"
|
||||
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
|
||||
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
|
||||
echo "ERROR: F5.1 migration chain is not idempotent." >&2
|
||||
cat "$rerun_log" >&2
|
||||
exit 1
|
||||
}
|
||||
rm -f "$rerun_log"
|
||||
- name: VPS-equivalent isolated API preflight
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
@@ -72,6 +224,9 @@ jobs:
|
||||
docker run --rm \
|
||||
-v "$PWD/api-v3/test:/app/test:ro" \
|
||||
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||
-v "$PWD/docker-compose.yml:/docker-compose.yml:ro" \
|
||||
-v "$PWD/web-v2:/web-v2:ro" \
|
||||
-v "$PWD/android-app:/android-app:ro" \
|
||||
"$image" npm test
|
||||
docker image rm "$image" >/dev/null 2>&1 || true
|
||||
- name: Build production images
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 19
|
||||
versionName = "0.12.0"
|
||||
versionCode = 20
|
||||
versionName = "0.13.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
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 asset = selectedFieldAsset?.asset
|
||||
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
|
||||
}
|
||||
if (asset == null) {
|
||||
@@ -163,15 +167,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
launchBusy {
|
||||
val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code)
|
||||
val created = actsRepository.create(
|
||||
currentVisit.id,
|
||||
asset.id,
|
||||
currentVisit.code,
|
||||
urgency,
|
||||
)
|
||||
selectedAct = created
|
||||
actClosure = actsRepository.closure(created.id)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
|
||||
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) {
|
||||
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
|
||||
}
|
||||
@@ -196,7 +206,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy {
|
||||
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
|
||||
val draft = selectedDraftAct()
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||
@@ -222,7 +232,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) {
|
||||
val visitId = visit?.id ?: return
|
||||
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
|
||||
}
|
||||
if (type.familyRequired && familyId == null) {
|
||||
@@ -449,26 +459,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
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() {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.prepare(actId)
|
||||
actClosure = actsRepository.lock(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() {
|
||||
val actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.reopen(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
notice = "Acta reabierta. Podés corregirla antes de volver a preparar."
|
||||
}
|
||||
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
|
||||
}
|
||||
|
||||
fun signSelectedActAsInspector(
|
||||
@@ -507,13 +512,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
fun recordCompanyOutcome(status: String, reason: String) {
|
||||
val actId = selectedAct?.id ?: return
|
||||
if (status != "REFUSED") {
|
||||
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
|
||||
return
|
||||
}
|
||||
if (reason.trim().length < 10) {
|
||||
error = "Indicá un motivo de al menos 10 caracteres."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
actClosure = actsRepository.companyOutcome(actId, status, reason)
|
||||
notice = 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 actId = selectedAct?.id ?: return
|
||||
launchBusy {
|
||||
actClosure = actsRepository.closeAct(actId)
|
||||
actClosure = actsRepository.seal(actId)
|
||||
refreshSelectedActInternal(actId)
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
clearFindingState()
|
||||
notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección."
|
||||
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 {
|
||||
visit = actsRepository.closeVisit(visitId)
|
||||
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(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val title: String? = null,
|
||||
val objective: String? = null,
|
||||
val status: String,
|
||||
val scopeAsset: AssetSummary? = null,
|
||||
@@ -153,7 +152,6 @@ data class ChecklistSummary(
|
||||
data class VisitDetail(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val title: String? = null,
|
||||
val objective: String? = null,
|
||||
val status: String,
|
||||
val operationalArea: AssetSummary? = null,
|
||||
|
||||
@@ -37,6 +37,15 @@ data class MobileActSummary(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val urgency: String = "NON_URGENT",
|
||||
val deadlineDays: Int? = null,
|
||||
val deadlineDayType: String? = null,
|
||||
val deadlineBasis: String? = null,
|
||||
val deadlineBaseAt: String? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val lockedAt: String? = null,
|
||||
val lockedSha256: String? = null,
|
||||
val sealedAt: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
@@ -53,6 +62,15 @@ data class MobileActDetail(
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
val urgency: String = "NON_URGENT",
|
||||
val deadlineDays: Int? = null,
|
||||
val deadlineDayType: String? = null,
|
||||
val deadlineBasis: String? = null,
|
||||
val deadlineBaseAt: String? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val lockedAt: String? = null,
|
||||
val lockedSha256: String? = null,
|
||||
val sealedAt: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
@@ -75,6 +93,7 @@ data class MobileActListResponse(
|
||||
|
||||
data class CreateMobileActRequest(
|
||||
val occurredAt: String,
|
||||
val urgency: String,
|
||||
val title: String,
|
||||
val summary: String,
|
||||
val observations: String? = null,
|
||||
@@ -113,6 +132,15 @@ data class MobileActClosureHeader(
|
||||
val code: String,
|
||||
val status: String,
|
||||
val visitId: String,
|
||||
val urgency: String = "NON_URGENT",
|
||||
val deadlineDays: Int? = null,
|
||||
val deadlineDayType: String? = null,
|
||||
val deadlineBasis: String? = null,
|
||||
val deadlineBaseAt: String? = null,
|
||||
val deadlineAt: String? = null,
|
||||
val lockedAt: String? = null,
|
||||
val lockedSha256: String? = null,
|
||||
val sealedAt: String? = null,
|
||||
val currentVersion: Int = 0,
|
||||
val closedAt: String? = null,
|
||||
val closureSha256: String? = null,
|
||||
@@ -169,7 +197,7 @@ data class MobileCompanyOutcomeRequest(
|
||||
val reason: String,
|
||||
)
|
||||
|
||||
data class MobileCloseActRequest(
|
||||
data class MobileSealActRequest(
|
||||
val clientClosedAt: String = Instant.now().toString(),
|
||||
val uploadMode: String = "ONLINE",
|
||||
)
|
||||
@@ -219,14 +247,8 @@ private interface MobileActsApi {
|
||||
@Body request: MobileResponsibleRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/ready")
|
||||
suspend fun ready(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/reopen")
|
||||
suspend fun reopen(
|
||||
@POST("inspection-acts/{actId}/lock")
|
||||
suspend fun lock(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
): MobileActClosure
|
||||
@@ -268,11 +290,11 @@ private interface MobileActsApi {
|
||||
@Body request: MobileCompanyOutcomeRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-acts/{actId}/close")
|
||||
suspend fun closeAct(
|
||||
@POST("inspection-acts/{actId}/seal")
|
||||
suspend fun sealAct(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("actId") actId: String,
|
||||
@Body request: MobileCloseActRequest,
|
||||
@Body request: MobileSealActRequest,
|
||||
): MobileActClosure
|
||||
|
||||
@POST("inspection-visits/{visitId}/close")
|
||||
@@ -305,14 +327,20 @@ class MobileActsRepository(context: Context) {
|
||||
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(
|
||||
"Bearer ${session.accessToken}",
|
||||
visitId,
|
||||
CreateMobileActRequest(
|
||||
occurredAt = Instant.now().toString(),
|
||||
urgency = urgency,
|
||||
title = "Acta de inspección $visitCode",
|
||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.",
|
||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
|
||||
assetIds = listOf(assetId),
|
||||
),
|
||||
)
|
||||
@@ -335,12 +363,8 @@ class MobileActsRepository(context: Context) {
|
||||
api.responsible("Bearer ${session.accessToken}", actId, request)
|
||||
}
|
||||
|
||||
suspend fun prepare(actId: String): MobileActClosure = authorized { session ->
|
||||
api.ready("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun reopen(actId: String): MobileActClosure = authorized { session ->
|
||||
api.reopen("Bearer ${session.accessToken}", actId)
|
||||
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
|
||||
api.lock("Bearer ${session.accessToken}", actId)
|
||||
}
|
||||
|
||||
suspend fun signInspector(
|
||||
@@ -378,8 +402,8 @@ class MobileActsRepository(context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun closeAct(actId: String): MobileActClosure = authorized { session ->
|
||||
api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest())
|
||||
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
|
||||
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
|
||||
}
|
||||
|
||||
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
|
||||
|
||||
@@ -60,6 +60,7 @@ fun MobileActsScreen(
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
|
||||
var attendance by rememberSaveable(selected?.id) {
|
||||
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
|
||||
}
|
||||
@@ -129,19 +130,20 @@ fun MobileActsScreen(
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
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()) {
|
||||
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
|
||||
}
|
||||
model.acts.forEach { act ->
|
||||
val active = selected?.id == act.id
|
||||
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
|
||||
OutlinedButton(
|
||||
onClick = { model.selectAct(act.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
(if (active) "✓ " else "") +
|
||||
"${act.code} · ${actStatusLabel(act.status)} · ${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)) {
|
||||
Text("Nueva Acta", fontWeight = FontWeight.Bold)
|
||||
if (model.acts.any { it.status == "READY" }) {
|
||||
if (model.acts.any { it.status == "LOCKED" }) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
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
|
||||
if (selectedInventory == null) {
|
||||
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 {
|
||||
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
|
||||
Button(
|
||||
onClick = { model.createActForSelectedInventory() },
|
||||
onClick = { model.createActForSelectedInventory(newActUrgency) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Crear nueva Acta") }
|
||||
@@ -186,9 +207,14 @@ fun MobileActsScreen(
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(actStatusLabel(selected.status))
|
||||
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
|
||||
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
|
||||
Text(selected.summary, style = MaterialTheme.typography.bodySmall)
|
||||
selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
|
||||
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,31 +260,28 @@ fun MobileActsScreen(
|
||||
|
||||
HorizontalDivider()
|
||||
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.")
|
||||
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") }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.")
|
||||
Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Preparar Acta para firmas") }
|
||||
) { Text("Finalizar y BLOQUEAR Acta") }
|
||||
}
|
||||
|
||||
"READY" -> {
|
||||
"LOCKED" -> {
|
||||
val signatures = closure?.signatures.orEmpty()
|
||||
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
||||
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
||||
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
|
||||
|
||||
Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (signatures.isEmpty()) {
|
||||
OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Volver a borrador")
|
||||
}
|
||||
}
|
||||
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||
@@ -274,29 +297,21 @@ fun MobileActsScreen(
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Recepción de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null) {
|
||||
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null && companyResolved) {
|
||||
val detail = when (companyOutcome.status) {
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada"
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
|
||||
"REFUSED" -> "Negativa a firmar"
|
||||
"ABSENT" -> "Responsable ausente"
|
||||
else -> companyOutcome.status
|
||||
}
|
||||
Text("✓ $detail", color = MaterialTheme.colorScheme.primary)
|
||||
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
||||
Text("El responsable fue registrado como ausente.")
|
||||
Button(
|
||||
onClick = {
|
||||
model.recordCompanyOutcome(
|
||||
"ABSENT",
|
||||
closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección",
|
||||
)
|
||||
},
|
||||
enabled = !model.busy && inspectorSigned,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Asentar ausencia en el Acta") }
|
||||
Text(
|
||||
"El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
} else {
|
||||
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -333,28 +348,28 @@ fun MobileActsScreen(
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
if (inspectorSigned && companyOutcome != null) {
|
||||
if (inspectorSigned && companyResolved) {
|
||||
Button(
|
||||
onClick = { model.closeSelectedAct() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Acta definitivamente") }
|
||||
} else if (inspectorSigned) {
|
||||
) { Text("SELLAR Acta definitivamente") }
|
||||
} else {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
"CLOSED" -> {
|
||||
"SEALED" -> {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold)
|
||||
Text("El PDF, informe Word y entregas documentales se generan desde este cierre.")
|
||||
Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
|
||||
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
|
||||
closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
@@ -366,16 +381,19 @@ fun MobileActsScreen(
|
||||
|
||||
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
val drafts = model.acts.count { it.status == "DRAFT" }
|
||||
Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.",
|
||||
)
|
||||
val activeActs = model.acts.filter { it.status != "CANCELLED" }
|
||||
val pendingActs = activeActs.filter { it.status != "SEALED" }
|
||||
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (pendingActs.isEmpty()) {
|
||||
Text("Todas las Actas están SELLADAS. La Inspección ya puede cerrarse.")
|
||||
} else {
|
||||
Text("No puede cerrarse todavía. Falta SELLAR: ${pendingActs.joinToString { it.code }}")
|
||||
}
|
||||
Button(
|
||||
onClick = { model.closeInspection() },
|
||||
enabled = !model.busy && drafts == 0,
|
||||
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
|
||||
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) {
|
||||
"DRAFT" -> "Borrador"
|
||||
"READY" -> "Preparada para firmas"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"LOCKED" -> "BLOQUEADA"
|
||||
"SEALED" -> "SELLADA"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.25.0-1",
|
||||
"version": "0.27.0-1",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"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
|
||||
FROM inspection_findings f WHERE f.act_id = ia.id
|
||||
) fc ON true
|
||||
WHERE ia.status IN ('CLOSED', 'RECTIFIED')
|
||||
WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
|
||||
), classified AS (
|
||||
SELECT *, CASE
|
||||
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 act = rows[0];
|
||||
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' });
|
||||
if (!['CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_CLOSED', message: 'El seguimiento administrativo comienza cuando el Acta está cerrada.' });
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ export class FieldBriefingService {
|
||||
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
|
||||
LIMIT 1
|
||||
) response ON true
|
||||
WHERE act.status IN ('CLOSED', 'RECTIFIED')
|
||||
WHERE act.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
|
||||
AND source_visit.id <> $1
|
||||
AND source_visit.operational_area_id = $2::uuid
|
||||
AND source_visit.operator_company_id = $3::uuid
|
||||
|
||||
@@ -15,8 +15,6 @@ import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { AssetMasterModule } from './asset-master/asset-master.module';
|
||||
import { SurveyPlanningModule } from './survey-planning/survey-planning.module';
|
||||
import { SurveyExecutionModule } from './survey-execution/survey-execution.module';
|
||||
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
|
||||
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
|
||||
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
|
||||
@@ -69,8 +67,6 @@ function required(config: ConfigService, key: string): string {
|
||||
AdministrationModule,
|
||||
DashboardModule,
|
||||
AssetMasterModule,
|
||||
SurveyPlanningModule,
|
||||
SurveyExecutionModule,
|
||||
InspectionVisitsModule,
|
||||
InspectionActsModule,
|
||||
InspectionFindingsModule,
|
||||
|
||||
@@ -41,14 +41,14 @@ export interface AssetVersionDetail extends AssetVersionSummary {
|
||||
function assetNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
code: 'ASSET_NOT_FOUND',
|
||||
message: 'Activo no encontrado',
|
||||
message: 'Inventario no encontrado',
|
||||
});
|
||||
}
|
||||
|
||||
function versionNotFound(): NotFoundException {
|
||||
return new NotFoundException({
|
||||
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);
|
||||
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);
|
||||
@@ -101,17 +101,8 @@ export class AssetHistoryService {
|
||||
asset_id, version_number, change_type, changed_fields, snapshot,
|
||||
actor_user_id, actor_username, source, request_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
assetId,
|
||||
versionNumber,
|
||||
changeType,
|
||||
changedFields,
|
||||
snapshot,
|
||||
principal.userId,
|
||||
principal.username,
|
||||
source,
|
||||
request.requestId,
|
||||
],
|
||||
[assetId, versionNumber, changeType, changedFields, snapshot,
|
||||
principal.userId, principal.username, source, request.requestId],
|
||||
);
|
||||
return versionNumber;
|
||||
}
|
||||
@@ -123,38 +114,21 @@ export class AssetHistoryService {
|
||||
parameters.push(value);
|
||||
return `$${parameters.length}`;
|
||||
};
|
||||
|
||||
if (query.search?.trim()) {
|
||||
const search = add(`%${query.search.trim()}%`);
|
||||
conditions.push(`(
|
||||
version.snapshot->>'code' ILIKE ${search}
|
||||
OR version.snapshot->>'name' ILIKE ${search}
|
||||
OR version.actor_username ILIKE ${search}
|
||||
)`);
|
||||
}
|
||||
if (query.typeId) {
|
||||
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
||||
}
|
||||
if (query.status) {
|
||||
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
||||
}
|
||||
if (query.changeType) {
|
||||
conditions.push(`version.change_type = ${add(query.changeType)}`);
|
||||
conditions.push(`(version.snapshot->>'code' ILIKE ${search} OR version.snapshot->>'name' ILIKE ${search} OR version.actor_username ILIKE ${search})`);
|
||||
}
|
||||
if (query.typeId) conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
|
||||
if (query.status) conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
|
||||
if (query.changeType) conditions.push(`version.change_type = ${add(query.changeType)}`);
|
||||
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
|
||||
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
|
||||
|
||||
return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
|
||||
}
|
||||
|
||||
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
|
||||
await this.requireAsset(assetId);
|
||||
return this.listWithConditions(
|
||||
query.page,
|
||||
query.pageSize,
|
||||
['version.asset_id = $1'],
|
||||
[assetId],
|
||||
);
|
||||
return this.listWithConditions(query.page, query.pageSize, ['version.asset_id = $1'], [assetId]);
|
||||
}
|
||||
|
||||
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
|
||||
@@ -170,17 +144,9 @@ export class AssetHistoryService {
|
||||
return row;
|
||||
}
|
||||
|
||||
private async listWithConditions(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
conditions: string[],
|
||||
parameters: unknown[],
|
||||
) {
|
||||
private async listWithConditions(page: number, pageSize: number, conditions: string[], parameters: unknown[]) {
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [countRow] = (await this.dataSource.query(
|
||||
`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`,
|
||||
parameters,
|
||||
)) as Array<{ total: number }>;
|
||||
const [countRow] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, parameters)) as Array<{ total: number }>;
|
||||
const total = Number(countRow?.total ?? 0);
|
||||
const paginated = [...parameters, pageSize, (page - 1) * pageSize];
|
||||
const limit = `$${parameters.length + 1}`;
|
||||
@@ -194,51 +160,29 @@ export class AssetHistoryService {
|
||||
LIMIT ${limit} OFFSET ${offset}`,
|
||||
paginated,
|
||||
)) 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 {
|
||||
return `SELECT
|
||||
version.id,
|
||||
version.asset_id AS "assetId",
|
||||
version.snapshot->>'code' AS "assetCode",
|
||||
version.snapshot->>'name' AS "assetName",
|
||||
version.snapshot #>> '{type,id}' AS "typeId",
|
||||
version.snapshot #>> '{type,name}' AS "typeName",
|
||||
version.id, version.asset_id AS "assetId",
|
||||
version.snapshot->>'code' AS "assetCode", version.snapshot->>'name' AS "assetName",
|
||||
version.snapshot #>> '{type,id}' AS "typeId", version.snapshot #>> '{type,name}' AS "typeName",
|
||||
version.snapshot->>'informationStatus' AS "informationStatus",
|
||||
version.snapshot->>'operationalStatus' AS "operationalStatus",
|
||||
version.version_number AS "versionNumber",
|
||||
version.change_type AS "changeType",
|
||||
version.changed_fields AS "changedFields",
|
||||
version.occurred_at AS "occurredAt",
|
||||
version.actor_user_id AS "actorUserId",
|
||||
version.actor_username AS "actorUsername",
|
||||
version.source,
|
||||
version.request_id AS "requestId",
|
||||
version.version_number AS "versionNumber", version.change_type AS "changeType",
|
||||
version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt",
|
||||
version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername",
|
||||
version.source, version.request_id AS "requestId",
|
||||
(version.version_number = current_asset.current_version) AS "isCurrent"`;
|
||||
}
|
||||
|
||||
private async requireAsset(assetId: string): Promise<void> {
|
||||
const [row] = (await this.dataSource.query(
|
||||
'SELECT 1 FROM assets WHERE id = $1',
|
||||
[assetId],
|
||||
)) as unknown[];
|
||||
const [row] = (await this.dataSource.query('SELECT 1 FROM assets WHERE id = $1', [assetId])) as unknown[];
|
||||
if (!row) throw assetNotFound();
|
||||
}
|
||||
|
||||
private async loadCurrentSnapshot(
|
||||
manager: EntityManager,
|
||||
assetId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
private async loadCurrentSnapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
|
||||
const [row] = (await manager.query(
|
||||
`SELECT JSONB_BUILD_OBJECT(
|
||||
'id', asset.id,
|
||||
@@ -246,82 +190,49 @@ export class AssetHistoryService {
|
||||
'name', asset.name,
|
||||
'commonName', asset.common_name,
|
||||
'description', asset.description,
|
||||
'type', JSONB_BUILD_OBJECT(
|
||||
'id', asset_type.id,
|
||||
'code', asset_type.code,
|
||||
'name', asset_type.name,
|
||||
'operationalRole', asset_type.operational_role
|
||||
),
|
||||
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', parent.id,
|
||||
'code', parent.code,
|
||||
'name', parent.name
|
||||
) END,
|
||||
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', operational_area.id,
|
||||
'code', operational_area.code,
|
||||
'name', operational_area.name
|
||||
) END,
|
||||
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', operator_company.id,
|
||||
'code', operator_company.code,
|
||||
'name', operator_company.name
|
||||
) END,
|
||||
'type', JSONB_BUILD_OBJECT('id', asset_type.id,'code', asset_type.code,'name', asset_type.name,'operationalRole', asset_type.operational_role),
|
||||
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', parent.id,'code', parent.code,'name', parent.name) END,
|
||||
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operational_area.id,'code', operational_area.code,'name', operational_area.name) END,
|
||||
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operator_company.id,'code', operator_company.code,'name', operator_company.name) END,
|
||||
'informationStatus', asset.information_status,
|
||||
'operationalStatus', asset.operational_status,
|
||||
'currentFunction', (
|
||||
SELECT JSONB_BUILD_OBJECT(
|
||||
'assignmentId', assignment.id,
|
||||
'id', fn.id,
|
||||
'code', fn.code,
|
||||
'name', fn.name,
|
||||
'validFrom', assignment.valid_from,
|
||||
'reason', assignment.change_reason
|
||||
)
|
||||
FROM inventory_function_assignments assignment
|
||||
JOIN inventory_functions fn ON fn.id=assignment.function_id
|
||||
WHERE assignment.asset_id=asset.id AND assignment.valid_until IS NULL
|
||||
ORDER BY assignment.valid_from DESC LIMIT 1
|
||||
),
|
||||
'attributes', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'definitionId', definition.id,
|
||||
'code', definition.code,
|
||||
'name', definition.name,
|
||||
'dataType', definition.data_type,
|
||||
'isRequired', definition.is_required,
|
||||
'unit', definition.unit,
|
||||
'options', definition.options,
|
||||
'sortOrder', definition.sort_order,
|
||||
'value', value.value
|
||||
'definitionId', definition.id,'code', definition.code,'name', definition.name,
|
||||
'dataType', definition.data_type,'isRequired', definition.is_required,
|
||||
'unit', definition.unit,'options', definition.options,'sortOrder', definition.sort_order,'value', value.value
|
||||
) ORDER BY definition.sort_order, definition.name)
|
||||
FROM asset_attribute_definitions definition
|
||||
LEFT JOIN asset_attribute_values value
|
||||
ON value.definition_id = definition.id
|
||||
AND value.asset_id = asset.id
|
||||
WHERE definition.asset_type_id = asset.asset_type_id
|
||||
AND definition.is_active = true
|
||||
LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id
|
||||
WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true
|
||||
), '[]'::jsonb),
|
||||
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'assetId', geometry.asset_id,
|
||||
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,
|
||||
'geometryType', geometry.geometry_type,
|
||||
'source', geometry.source,
|
||||
'accuracyM', geometry.accuracy_m::double precision,
|
||||
'capturedAt', geometry.captured_at,
|
||||
'deviceLabel', geometry.device_label,
|
||||
'createdAt', geometry.created_at,
|
||||
'updatedAt', geometry.updated_at,
|
||||
'updatedBy', geometry.updated_by
|
||||
'assetId', geometry.asset_id,'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,'geometryType', geometry.geometry_type,
|
||||
'source', geometry.source,'accuracyM', geometry.accuracy_m::double precision,'capturedAt', geometry.captured_at,
|
||||
'deviceLabel', geometry.device_label,'createdAt', geometry.created_at,'updatedAt', geometry.updated_at,'updatedBy', geometry.updated_by
|
||||
) END,
|
||||
'media', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
||||
'id', media.id,
|
||||
'kind', media.kind,
|
||||
'originalName', media.original_name,
|
||||
'mimeType', media.mime_type,
|
||||
'sizeBytes', media.size_bytes,
|
||||
'sha256', media.sha256,
|
||||
'title', media.title,
|
||||
'description', media.description,
|
||||
'capturedAt', media.captured_at,
|
||||
'latitude', media.latitude,
|
||||
'longitude', media.longitude,
|
||||
'accuracyM', media.accuracy_m,
|
||||
'source', media.source,
|
||||
'uploadedBy', media.uploaded_by,
|
||||
'createdAt', media.created_at,
|
||||
'updatedAt', media.updated_at
|
||||
'id', media.id,'kind', media.kind,'originalName', media.original_name,'mimeType', media.mime_type,
|
||||
'sizeBytes', media.size_bytes,'sha256', media.sha256,'title', media.title,'description', media.description,
|
||||
'capturedAt', media.captured_at,'latitude', media.latitude,'longitude', media.longitude,'accuracyM', media.accuracy_m,
|
||||
'source', media.source,'uploadedBy', media.uploaded_by,'createdAt', media.created_at,'updatedAt', media.updated_at
|
||||
) ORDER BY media.created_at, media.id)
|
||||
FROM asset_media media
|
||||
WHERE media.asset_id = asset.id
|
||||
AND media.deleted_at IS NULL
|
||||
FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL
|
||||
), '[]'::jsonb),
|
||||
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
|
||||
'organizationMemberships', COALESCE((
|
||||
@@ -337,22 +248,8 @@ export class AssetHistoryService {
|
||||
'legalRights', COALESCE((
|
||||
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
|
||||
),'[]'::jsonb),
|
||||
'provenance', JSONB_BUILD_OBJECT(
|
||||
'origin', asset.data_origin,
|
||||
'sourceName', asset.source_name,
|
||||
'sourceReference', asset.source_reference,
|
||||
'observedAt', asset.source_observed_at,
|
||||
'notes', asset.source_notes,
|
||||
'verifiedAt', asset.provenance_verified_at,
|
||||
'verifiedBy', asset.provenance_verified_by,
|
||||
'updatedAt', asset.provenance_updated_at,
|
||||
'updatedBy', asset.provenance_updated_by
|
||||
),
|
||||
'createdAt', asset.created_at,
|
||||
'updatedAt', asset.updated_at,
|
||||
'createdBy', asset.created_by,
|
||||
'updatedBy', asset.updated_by,
|
||||
'currentVersion', asset.current_version
|
||||
'provenance', JSONB_BUILD_OBJECT('origin', asset.data_origin,'sourceName', asset.source_name,'sourceReference', asset.source_reference,'observedAt', asset.source_observed_at,'notes', asset.source_notes,'verifiedAt', asset.provenance_verified_at,'verifiedBy', asset.provenance_verified_by,'updatedAt', asset.provenance_updated_at,'updatedBy', asset.provenance_updated_by),
|
||||
'createdAt', asset.created_at,'updatedAt', asset.updated_at,'createdBy', asset.created_by,'updatedBy', asset.updated_by,'currentVersion', asset.current_version
|
||||
) AS snapshot
|
||||
FROM assets asset
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
|
||||
@@ -26,9 +26,12 @@ import { InventoryStructureController } from './inventory-structure.controller';
|
||||
import { InventoryStructureService } from './inventory-structure.service';
|
||||
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
|
||||
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||
import { InventoryBrowserController } from './inventory-browser.controller';
|
||||
import { InventoryBrowserService } from './inventory-browser.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
@@ -37,6 +40,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
|
||||
AssetsController,
|
||||
InventoryStructureController,
|
||||
InventoryFamilyCatalogController,
|
||||
InventoryBrowserController,
|
||||
InventoryMergeController,
|
||||
FieldInventoryMergeController,
|
||||
AssetGeometriesController,
|
||||
@@ -53,6 +57,8 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
|
||||
AssetsService,
|
||||
InventoryStructureService,
|
||||
InventoryFamilyCatalogService,
|
||||
InventoryFunctionService,
|
||||
InventoryBrowserService,
|
||||
InventoryMergeService,
|
||||
MergedInventoryDossierService,
|
||||
AssetGeometriesService,
|
||||
|
||||
@@ -79,23 +79,15 @@ export class AssetOperationalRelationsService {
|
||||
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
|
||||
const data = (await this.dataSource.query(`
|
||||
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName"
|
||||
FROM (
|
||||
SELECT relation.company_id
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.area_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
UNION
|
||||
SELECT asset.operator_company_id AS company_id
|
||||
FROM assets asset
|
||||
WHERE asset.operational_area_id = $1
|
||||
AND asset.operator_company_id IS NOT NULL
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
) linked
|
||||
INNER JOIN assets company ON company.id = linked.company_id
|
||||
SELECT DISTINCT company.id, company.code, company.name,
|
||||
company.common_name AS "commonName", company_type.name AS "typeName"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets company ON company.id = relation.company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
||||
WHERE 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
|
||||
`, [areaId])) as OperationalAssetSummary[];
|
||||
return { data };
|
||||
@@ -104,23 +96,15 @@ export class AssetOperationalRelationsService {
|
||||
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
|
||||
const data = (await this.dataSource.query(`
|
||||
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName"
|
||||
FROM (
|
||||
SELECT relation.area_id
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.company_id = $1
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
UNION
|
||||
SELECT asset.operational_area_id AS area_id
|
||||
FROM assets asset
|
||||
WHERE asset.operator_company_id = $1
|
||||
AND asset.operational_area_id IS NOT NULL
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
) linked
|
||||
INNER JOIN assets area ON area.id = linked.area_id
|
||||
SELECT DISTINCT area.id, area.code, area.name,
|
||||
area.common_name AS "commonName", area_type.name AS "typeName"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets area ON area.id = relation.area_id
|
||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||
WHERE 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
|
||||
`, [companyId])) as OperationalAssetSummary[];
|
||||
return { data };
|
||||
@@ -157,12 +141,49 @@ export class AssetOperationalRelationsService {
|
||||
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
|
||||
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
|
||||
}
|
||||
|
||||
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
|
||||
const [currentOperator] = (await manager.query(`
|
||||
SELECT relation.id,company.name AS "companyName"
|
||||
FROM area_company_relations relation
|
||||
JOIN assets company ON company.id=relation.company_id
|
||||
WHERE relation.area_id=$1
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
AND relation.company_id<>$2
|
||||
ORDER BY relation.valid_from DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE OF relation
|
||||
`,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>;
|
||||
if (currentOperator) {
|
||||
throw new ConflictException({
|
||||
code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST',
|
||||
message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [row] = (await manager.query(`
|
||||
INSERT INTO area_company_relations (
|
||||
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
|
||||
|
||||
// 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);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
@@ -170,6 +191,9 @@ export class AssetOperationalRelationsService {
|
||||
entityType: 'area_company_relation',
|
||||
entityId: row.id,
|
||||
afterData: this.auditView(created),
|
||||
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
|
||||
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
|
||||
: undefined,
|
||||
}, manager);
|
||||
return created;
|
||||
});
|
||||
@@ -198,12 +222,9 @@ export class AssetOperationalRelationsService {
|
||||
message: 'La relación ya se encuentra finalizada',
|
||||
});
|
||||
}
|
||||
if (before.assignedAssetCount > 0) {
|
||||
throw new ConflictException({
|
||||
code: 'AREA_COMPANY_RELATION_IN_USE',
|
||||
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
|
||||
});
|
||||
}
|
||||
|
||||
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
|
||||
// Ending an operator relation must never be blocked by existing Inventory.
|
||||
await manager.query(`
|
||||
UPDATE area_company_relations
|
||||
SET valid_until = CURRENT_TIMESTAMP,
|
||||
@@ -220,6 +241,10 @@ export class AssetOperationalRelationsService {
|
||||
entityId: id,
|
||||
beforeData: this.auditView(before),
|
||||
afterData: this.auditView(updated),
|
||||
metadata: {
|
||||
inventoryHierarchyChanged:false,
|
||||
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
|
||||
},
|
||||
}, manager);
|
||||
return updated;
|
||||
});
|
||||
@@ -330,7 +355,8 @@ export class AssetOperationalRelationsService {
|
||||
(relation.valid_until IS NULL) AS active,
|
||||
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
|
||||
WHERE asset.operational_area_id = relation.area_id
|
||||
AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount"
|
||||
AND asset.operator_company_id = relation.company_id
|
||||
AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets area ON area.id = relation.area_id
|
||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||
|
||||
@@ -298,7 +298,7 @@ export class AssetsService {
|
||||
|
||||
const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([
|
||||
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.actual_started_at AS "actualStartedAt",
|
||||
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,
|
||||
act.occurred_at AS "occurredAt", act.title, act.summary,
|
||||
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
|
||||
JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
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.created_at AS "createdAt", finding.updated_at AS "updatedAt",
|
||||
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
|
||||
JOIN inspection_acts act ON act.id = finding.act_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.rescheduled_control_on AS "rescheduledControlOn",
|
||||
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
|
||||
FROM inspection_finding_evidence verification_evidence
|
||||
WHERE verification_evidence.finding_id = finding.id
|
||||
@@ -484,7 +484,7 @@ export class AssetsService {
|
||||
kind: 'INSPECTION',
|
||||
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
|
||||
title: `Inspección ${String(visit.code)}`,
|
||||
description: visit.title,
|
||||
description: null,
|
||||
href: `/inspecciones/${String(visit.id)}`,
|
||||
meta: { status: visit.status },
|
||||
}));
|
||||
@@ -867,7 +867,7 @@ export class AssetsService {
|
||||
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",
|
||||
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,
|
||||
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"
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
export const INVENTORY_STRUCTURE_KINDS = [
|
||||
'EMPRESA',
|
||||
'DEPARTAMENTO',
|
||||
'AREA',
|
||||
'YACIMIENTO',
|
||||
'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)
|
||||
@IsBoolean()
|
||||
hasGeometry?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||
@IsBoolean()
|
||||
inventoryOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ export class ListAssetsQueryDto {
|
||||
@IsEnum(AssetInformationStatus)
|
||||
status?: AssetInformationStatus;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetOperationalStatus)
|
||||
operationalStatus?: AssetOperationalStatus;
|
||||
@@ -54,6 +53,11 @@ export class ListAssetsQueryDto {
|
||||
@IsBoolean()
|
||||
hasGeometry?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
|
||||
@IsBoolean()
|
||||
inventoryOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parentId?: string;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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('departments')
|
||||
departments(@Query() query: InventoryBrowserQueryDto) {
|
||||
return this.inventoryBrowser.departments(query);
|
||||
}
|
||||
|
||||
@Get('companies')
|
||||
companies(@Query() query: InventoryBrowserQueryDto) {
|
||||
return this.inventoryBrowser.companies(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,335 @@
|
||||
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.information_status<>'INACTIVE'",
|
||||
'type.is_active=true',
|
||||
"(type.operational_role='COMPANY' OR lower(type.code) IN ('departamento','area','yacimiento','instalacion','subinstalacion'))",
|
||||
];
|
||||
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) {
|
||||
const area = add(query.operationalAreaId);
|
||||
conditions.push(`(asset.id=${area}::uuid OR asset.operational_area_id=${area}::uuid)`);
|
||||
}
|
||||
if (query.operatorCompanyId) {
|
||||
const company = add(query.operatorCompanyId);
|
||||
conditions.push(`(
|
||||
asset.id=${company}::uuid
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM area_company_relations relation
|
||||
WHERE (relation.area_id=asset.operational_area_id OR relation.area_id=asset.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=COALESCE(asset.operational_area_id,
|
||||
CASE WHEN type.operational_role='AREA' THEN asset.id ELSE NULL END)
|
||||
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",
|
||||
(SELECT COUNT(*)::integer FROM assets child
|
||||
WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') 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 CASE
|
||||
WHEN lower(type.code)='departamento' THEN 0
|
||||
WHEN type.operational_role='COMPANY' THEN 1
|
||||
WHEN lower(type.code)='area' THEN 2
|
||||
WHEN lower(type.code)='yacimiento' THEN 3
|
||||
WHEN lower(type.code)='instalacion' THEN 4
|
||||
WHEN lower(type.code)='subinstalacion' THEN 5 ELSE 9 END,
|
||||
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 departments(query: InventoryBrowserQueryDto) {
|
||||
const params: unknown[] = [];
|
||||
const conditions = [
|
||||
"lower(type.code)='departamento'",
|
||||
"department.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(`(department.code ILIKE ${p} OR department.name ILIKE ${p} OR COALESCE(department.common_name,'') ILIKE ${p})`);
|
||||
}
|
||||
if (query.operatorCompanyId) {
|
||||
const p = add(query.operatorCompanyId);
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1
|
||||
FROM assets area
|
||||
JOIN asset_types atype ON atype.id=area.asset_type_id
|
||||
JOIN area_company_relations relation ON relation.area_id=area.id
|
||||
WHERE area.parent_id=department.id
|
||||
AND lower(atype.code)='area'
|
||||
AND relation.company_id=${p}::uuid
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL
|
||||
)`);
|
||||
}
|
||||
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
department.id,department.code,department.name,department.common_name AS "commonName",
|
||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||
department.information_status AS "informationStatus",
|
||||
department.operational_status AS "operationalStatus",
|
||||
(SELECT COUNT(*)::integer FROM assets area
|
||||
JOIN asset_types atype ON atype.id=area.asset_type_id
|
||||
WHERE area.parent_id=department.id AND lower(atype.code)='area'
|
||||
AND area.information_status<>'INACTIVE') AS "areaCount",
|
||||
(SELECT COUNT(*)::integer FROM assets child
|
||||
WHERE child.parent_id=department.id AND child.information_status<>'INACTIVE') AS "childrenCount"
|
||||
FROM assets department
|
||||
JOIN asset_types type ON type.id=department.asset_type_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY department.name,department.code
|
||||
`, params);
|
||||
return { data, meta: { count: data.length } };
|
||||
}
|
||||
|
||||
async companies(query: InventoryBrowserQueryDto) {
|
||||
const params: unknown[] = [];
|
||||
const conditions = [
|
||||
"type.operational_role='COMPANY'",
|
||||
"company.information_status<>'INACTIVE'",
|
||||
'type.is_active=true',
|
||||
];
|
||||
if (query.search?.trim()) {
|
||||
params.push(`%${query.search.trim()}%`);
|
||||
conditions.push(`(company.code ILIKE $1 OR company.name ILIKE $1 OR COALESCE(company.common_name,'') ILIKE $1)`);
|
||||
}
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT company.id,company.code,company.name,company.common_name AS "commonName",
|
||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||
company.information_status AS "informationStatus",
|
||||
(SELECT COUNT(*)::integer FROM area_company_relations relation
|
||||
WHERE relation.company_id=company.id AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_until IS NULL) AS "areaCount"
|
||||
FROM assets company
|
||||
JOIN asset_types type ON type.id=company.asset_type_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY company.name,company.code
|
||||
`,params);
|
||||
return { data,meta:{count:data.length} };
|
||||
}
|
||||
|
||||
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.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 (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') AS "childrenCount",
|
||||
CASE WHEN family.id IS NULL THEN 0 ELSE (
|
||||
SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping
|
||||
WHERE mapping.inventory_family_id=family.id
|
||||
) END AS "findingCount",
|
||||
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 (!['departamento','area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
|
||||
throw new BadRequestException({
|
||||
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
|
||||
message:'La navegación de Inventarios admite Departamento → Área → Yacimiento → Instalación → Subinstalación',
|
||||
});
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
private allowedChildType(typeCode: string): string | null {
|
||||
switch (typeCode.toLowerCase()) {
|
||||
case 'departamento': return 'area';
|
||||
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 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';
|
||||
|
||||
@Controller('inventory-families')
|
||||
export class InventoryFamilyCatalogController {
|
||||
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')
|
||||
@RequirePermissions('assets.read')
|
||||
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
|
||||
|
||||
@@ -1,28 +1,71 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
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()
|
||||
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) {
|
||||
const [family] = await this.dataSource.query(`
|
||||
SELECT id,code,name,level,information_labels AS "informationLabels"
|
||||
FROM inventory_families
|
||||
WHERE id=$1::uuid AND is_active=true
|
||||
`, [familyId]) as Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: string;
|
||||
informationLabels: string[];
|
||||
}>;
|
||||
if (!family) {
|
||||
throw new NotFoundException({
|
||||
code: 'INVENTORY_FAMILY_NOT_FOUND',
|
||||
message: 'La familia técnica no existe',
|
||||
});
|
||||
}
|
||||
const family = await this.family(familyId, false);
|
||||
const items = await this.dataSource.query(`
|
||||
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
|
||||
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
|
||||
@@ -36,4 +79,221 @@ export class InventoryFamilyCatalogService {
|
||||
`, [familyId]);
|
||||
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;
|
||||
};
|
||||
|
||||
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']);
|
||||
|
||||
@Injectable()
|
||||
@@ -161,6 +177,19 @@ export class InventoryMergeService {
|
||||
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
||||
|
||||
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
|
||||
? await this.resolveCanonicalId(manager, source.parentId)
|
||||
: 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([
|
||||
this.snapshot(manager, source.id),
|
||||
this.snapshot(manager, canonical.id),
|
||||
@@ -279,6 +310,17 @@ export class InventoryMergeService {
|
||||
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 = {
|
||||
merge: mergeRecord,
|
||||
source: { id: source.id, code: source.code, name: source.name },
|
||||
@@ -286,6 +328,7 @@ export class InventoryMergeService {
|
||||
sourceVersionNumber,
|
||||
reparentedChildIds,
|
||||
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
|
||||
documentaryInvariantsVerified: true,
|
||||
};
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
@@ -298,10 +341,12 @@ export class InventoryMergeService {
|
||||
operation: 'CHRONOLOGICAL_MERGE',
|
||||
sourceAssetId: source.id,
|
||||
canonicalAssetId: canonical.id,
|
||||
physicalAreaId: sourceAreaId,
|
||||
reason: dto.reason,
|
||||
sourceVersionNumber,
|
||||
reparentedChildIds,
|
||||
historicalReferencesRewritten: false,
|
||||
documentaryInvariantsVerified: true,
|
||||
},
|
||||
}, manager);
|
||||
return result;
|
||||
@@ -322,14 +367,59 @@ export class InventoryMergeService {
|
||||
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
|
||||
message: 'El registro canónico no puede estar inactivo',
|
||||
});
|
||||
if (!source.operationalAreaId || !source.operatorCompanyId
|
||||
|| source.operationalAreaId !== canonical.operationalAreaId
|
||||
|| source.operatorCompanyId !== canonical.operatorCompanyId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH',
|
||||
message: 'Los registros deben pertenecer a la misma Área y Operadora',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
|
||||
const rows = (await manager.query(`
|
||||
WITH RECURSIVE lineage AS (
|
||||
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> {
|
||||
|
||||
@@ -36,19 +36,21 @@ type ParentRow = {
|
||||
code: string;
|
||||
name: string;
|
||||
typeCode: string;
|
||||
operationalAreaId: string | null;
|
||||
operatorCompanyId: string | null;
|
||||
inventoryFamilyId: string | null;
|
||||
};
|
||||
type IdRow = { id: string };
|
||||
|
||||
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = {
|
||||
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
|
||||
DEPARTAMENTO: 'departamento',
|
||||
AREA: 'area',
|
||||
YACIMIENTO: 'yacimiento',
|
||||
INSTALACION: 'instalacion',
|
||||
SUBINSTALACION: 'subinstalacion',
|
||||
};
|
||||
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
|
||||
AREA: null,
|
||||
EMPRESA: null,
|
||||
DEPARTAMENTO: null,
|
||||
AREA: 'departamento',
|
||||
YACIMIENTO: 'area',
|
||||
INSTALACION: 'yacimiento',
|
||||
SUBINSTALACION: 'instalacion',
|
||||
@@ -70,18 +72,31 @@ export class InventoryStructureService {
|
||||
const types = (await this.dataSource.query(`
|
||||
SELECT id,code,name
|
||||
FROM asset_types
|
||||
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
|
||||
AND is_active=true
|
||||
ORDER BY CASE lower(code)
|
||||
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2
|
||||
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END
|
||||
WHERE (
|
||||
lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion')
|
||||
OR operational_role='COMPANY'
|
||||
) AND is_active=true
|
||||
ORDER BY CASE
|
||||
WHEN operational_role='COMPANY' THEN 0
|
||||
WHEN lower(code)='departamento' THEN 1
|
||||
WHEN lower(code)='area' THEN 2
|
||||
WHEN lower(code)='yacimiento' THEN 3
|
||||
WHEN lower(code)='instalacion' THEN 4
|
||||
WHEN lower(code)='subinstalacion' THEN 5 ELSE 9 END
|
||||
`)) as StructureTypeRow[];
|
||||
if (types.length !== 4) {
|
||||
const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
|
||||
const departamento = types.find((item) => item.code.toLowerCase()==='departamento');
|
||||
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 || !departamento || !area || !yacimiento || !instalacion || !subinstalacion) {
|
||||
throw new ConflictException({
|
||||
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(`
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
family.legacy_type_code AS "legacyTypeCode",
|
||||
@@ -93,12 +108,17 @@ export class InventoryStructureService {
|
||||
WHERE family.is_active=true
|
||||
ORDER BY family.level,family.name,family.code
|
||||
`)) as FamilyRow[];
|
||||
|
||||
return {
|
||||
independentMasters: [
|
||||
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
|
||||
],
|
||||
levels: [
|
||||
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false },
|
||||
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
|
||||
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
|
||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
|
||||
{ kind: 'DEPARTAMENTO', label: 'Departamento', type: departamento, parentKind: null, requiresFamily: false },
|
||||
{ kind: 'AREA', label: 'Área', type: area, parentKind: 'DEPARTAMENTO', requiresFamily: false },
|
||||
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
|
||||
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
|
||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
|
||||
],
|
||||
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
|
||||
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
|
||||
@@ -107,7 +127,7 @@ export class InventoryStructureService {
|
||||
|
||||
async parents(kindValue: string, search?: string) {
|
||||
const kind = kindValue.toUpperCase() as InventoryStructureKind;
|
||||
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') {
|
||||
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'DEPARTAMENTO' || kind === 'EMPRESA') {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
|
||||
message: 'El nivel indicado no requiere un registro padre',
|
||||
@@ -139,7 +159,7 @@ export class InventoryStructureService {
|
||||
AND asset.information_status<>'INACTIVE'
|
||||
${searchSql}
|
||||
ORDER BY asset.name,asset.code
|
||||
LIMIT 80
|
||||
LIMIT 100
|
||||
`, parameters);
|
||||
return { data: rows };
|
||||
}
|
||||
@@ -154,9 +174,10 @@ export class InventoryStructureService {
|
||||
const type = await this.requireStructureType(manager, dto.kind);
|
||||
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
|
||||
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
|
||||
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
||||
const operationalAreaId = parent?.operationalAreaId ?? null;
|
||||
const operatorCompanyId = parent?.operatorCompanyId ?? null;
|
||||
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
|
||||
const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind)
|
||||
? await this.resolveAreaId(manager, parent)
|
||||
: null;
|
||||
|
||||
const inserted = (await manager.query(`
|
||||
INSERT INTO assets (
|
||||
@@ -164,30 +185,37 @@ export class InventoryStructureService {
|
||||
code,name,common_name,description,information_status,operational_status,
|
||||
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
||||
) VALUES (
|
||||
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,
|
||||
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status,
|
||||
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid
|
||||
$1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
|
||||
$5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
|
||||
$11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
|
||||
) RETURNING id
|
||||
`, [
|
||||
type.id,
|
||||
parent?.id ?? null,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
family?.id ?? null,
|
||||
code,
|
||||
generatedCode,
|
||||
dto.name,
|
||||
dto.commonName ?? null,
|
||||
dto.description ?? null,
|
||||
AssetInformationStatus.DRAFT,
|
||||
AssetOperationalStatus.UNKNOWN,
|
||||
AssetDataOrigin.MANUAL,
|
||||
'Inventario estructural F3.1',
|
||||
`inventory-structure:${dto.kind.toLowerCase()}`,
|
||||
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
|
||||
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F5.1' : 'Estructura manual de Inventario F5.1',
|
||||
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
|
||||
family ? `Clasificación técnica: ${family.code} · ${family.name}` : null,
|
||||
principal.userId,
|
||||
])) as Array<{ id: string }>;
|
||||
const id = inserted[0]?.id;
|
||||
if (!id) throw new Error('No se pudo crear el registro estructural');
|
||||
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(
|
||||
manager,
|
||||
@@ -200,13 +228,12 @@ export class InventoryStructureService {
|
||||
INSERT INTO asset_context_history (
|
||||
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
|
||||
change_reason,asset_version_number,source,request_id,created_by
|
||||
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
|
||||
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
|
||||
`, [
|
||||
id,
|
||||
parent?.id ?? null,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
'Alta guiada de Inventario estructural F3.1',
|
||||
dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F5.1' : 'Alta manual de estructura de Inventario F5.1',
|
||||
versionNumber,
|
||||
request.requestId,
|
||||
principal.userId,
|
||||
@@ -223,6 +250,7 @@ export class InventoryStructureService {
|
||||
inventoryStructureKind: dto.kind,
|
||||
inventoryFamilyId: family?.id ?? null,
|
||||
inventoryFamilyCode: family?.code ?? null,
|
||||
operatorOwnership: false,
|
||||
},
|
||||
}, manager);
|
||||
return created;
|
||||
@@ -231,7 +259,7 @@ export class InventoryStructureService {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({
|
||||
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;
|
||||
@@ -239,10 +267,11 @@ export class InventoryStructureService {
|
||||
}
|
||||
|
||||
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
||||
const rows = (await manager.query(`
|
||||
SELECT id,code,name FROM asset_types
|
||||
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1
|
||||
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[];
|
||||
const sql = kind === 'EMPRESA'
|
||||
? `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`
|
||||
: `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`;
|
||||
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]) {
|
||||
throw new ConflictException({
|
||||
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
|
||||
@@ -261,8 +290,10 @@ export class InventoryStructureService {
|
||||
if (!expectedType) {
|
||||
if (parentId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_AREA_MUST_BE_ROOT',
|
||||
message: 'Un Área se crea como registro raíz y no puede tener padre',
|
||||
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
|
||||
message: kind === 'EMPRESA'
|
||||
? 'Una Empresa es un maestro independiente y no puede tener padre'
|
||||
: 'Un Departamento es un registro raíz y no puede tener padre',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
@@ -275,8 +306,6 @@ export class InventoryStructureService {
|
||||
}
|
||||
const rows = (await manager.query(`
|
||||
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
||||
asset.operational_area_id AS "operationalAreaId",
|
||||
asset.operator_company_id AS "operatorCompanyId",
|
||||
asset.inventory_family_id AS "inventoryFamilyId"
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
@@ -288,12 +317,36 @@ export class InventoryStructureService {
|
||||
if (parent.typeCode.toLowerCase() !== expectedType) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
|
||||
message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación',
|
||||
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
|
||||
});
|
||||
}
|
||||
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(
|
||||
manager: EntityManager,
|
||||
kind: InventoryStructureKind,
|
||||
@@ -304,13 +357,13 @@ export class InventoryStructureService {
|
||||
if (!expectedLevel) {
|
||||
if (familyId) throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
|
||||
message: 'Área y Yacimiento no llevan familia técnica',
|
||||
message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!familyId) throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
|
||||
message: `Elegí la familia técnica de la ${kind.toLowerCase()}`,
|
||||
message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`,
|
||||
});
|
||||
const rows = (await manager.query(`
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
@@ -324,22 +377,27 @@ export class InventoryStructureService {
|
||||
LIMIT 1
|
||||
`, [familyId])) as FamilyRow[];
|
||||
const family = rows[0];
|
||||
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' });
|
||||
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' });
|
||||
if (family.level !== expectedLevel) throw new BadRequestException({
|
||||
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
|
||||
message: 'La familia técnica no corresponde al nivel seleccionado',
|
||||
message: 'La clasificación técnica no corresponde al nivel seleccionado',
|
||||
});
|
||||
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
|
||||
message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada',
|
||||
message: 'La Subinstalación elegida no pertenece a la clasificación de la Instalación seleccionada',
|
||||
});
|
||||
}
|
||||
return family;
|
||||
}
|
||||
|
||||
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
||||
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
|
||||
const prefix = kind === 'EMPRESA' ? 'EMP'
|
||||
: kind === 'DEPARTAMENTO' ? 'DEP'
|
||||
: kind === 'AREA' ? 'AREA'
|
||||
: kind === 'YACIMIENTO' ? 'YAC'
|
||||
: kind === 'INSTALACION' ? 'INST'
|
||||
: 'SUB';
|
||||
const readable = name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
@@ -356,6 +414,7 @@ export class InventoryStructureService {
|
||||
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
|
||||
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
|
||||
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
||||
CASE WHEN 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(
|
||||
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
|
||||
'informationLabels',family.information_labels
|
||||
@@ -364,6 +423,7 @@ export class InventoryStructureService {
|
||||
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 inventory_families family ON family.id=asset.inventory_family_id
|
||||
WHERE asset.id=$1::uuid
|
||||
`, [id]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
|
||||
type LooseRecord = Record<string, any>;
|
||||
@@ -29,6 +30,7 @@ export class MergedInventoryDossierService {
|
||||
constructor(
|
||||
private readonly assets: AssetsService,
|
||||
private readonly merges: InventoryMergeService,
|
||||
private readonly functions: InventoryFunctionService,
|
||||
) {}
|
||||
|
||||
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 dossier = await this.assets.dossier(assetId) 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 => ({
|
||||
@@ -75,6 +78,15 @@ export class MergedInventoryDossierService {
|
||||
const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']);
|
||||
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 }) =>
|
||||
((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({
|
||||
...event,
|
||||
@@ -91,6 +103,25 @@ export class MergedInventoryDossierService {
|
||||
);
|
||||
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) {
|
||||
timeline.push({
|
||||
id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`,
|
||||
@@ -121,6 +152,7 @@ export class MergedInventoryDossierService {
|
||||
code: canonical.code,
|
||||
name: canonical.name,
|
||||
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
|
||||
currentFunction,
|
||||
},
|
||||
requestedAsset: {
|
||||
id: requested.id,
|
||||
@@ -146,7 +178,10 @@ export class MergedInventoryDossierService {
|
||||
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
|
||||
photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length,
|
||||
reports: reports.length + inspectionReports.length,
|
||||
functionChanges: functionHistory.length,
|
||||
},
|
||||
currentFunction,
|
||||
functionHistory,
|
||||
visits,
|
||||
acts,
|
||||
findings,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export interface DashboardAuditItem {
|
||||
export interface DashboardInspectorActivityItem {
|
||||
id: string;
|
||||
occurredAt: Date;
|
||||
actorUsername: string | null;
|
||||
@@ -19,12 +19,13 @@ interface DashboardCountsRow {
|
||||
inactiveUsers: number | string;
|
||||
activeSessions: number | string;
|
||||
openFindings: number | string;
|
||||
awaitingCompanyResponse: number | string;
|
||||
overdueCompanyResponses: number | string;
|
||||
companyResponsesDueNext7Days: number | string;
|
||||
awaitingVerificationSchedule: number | string;
|
||||
actsInFollowUp: number | string;
|
||||
findingsWithoutControlDate: number | string;
|
||||
overdueControls: number | string;
|
||||
controlsNext30Days: number | string;
|
||||
reportsWorking: number | string;
|
||||
reportsOfficialized: number | string;
|
||||
sealedActsWithoutReport: number | string;
|
||||
}
|
||||
|
||||
export interface DashboardUpcomingControl {
|
||||
@@ -49,9 +50,14 @@ export class DashboardService {
|
||||
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
|
||||
const [counts] = (await manager.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets",
|
||||
(SELECT COUNT(*) FROM assets WHERE 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 WHERE is_inventory_instance=true AND information_status <> 'INACTIVE') AS "totalAssets",
|
||||
(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.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 users WHERE status = 'ACTIVE') AS "activeUsers",
|
||||
(SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers",
|
||||
@@ -61,50 +67,74 @@ export class DashboardService {
|
||||
WHERE revoked_at IS NULL
|
||||
AND expires_at > CURRENT_TIMESTAMP
|
||||
) AS "activeSessions",
|
||||
(SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN') AS "openFindings",
|
||||
(
|
||||
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN'
|
||||
) AS "openFindings",
|
||||
SELECT COUNT(*)
|
||||
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
|
||||
WHERE status = 'OPEN' AND company_response_received_on IS NULL
|
||||
) AS "awaitingCompanyResponse",
|
||||
SELECT COUNT(*)
|
||||
FROM inspection_findings finding
|
||||
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
|
||||
WHERE status = 'OPEN'
|
||||
AND company_response_received_on IS NULL
|
||||
AND correction_due_on IS NOT NULL
|
||||
AND correction_due_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||
) AS "overdueCompanyResponses",
|
||||
(
|
||||
SELECT COUNT(*) FROM inspection_findings
|
||||
WHERE status = 'OPEN'
|
||||
AND company_response_received_on IS NULL
|
||||
AND correction_due_on BETWEEN
|
||||
(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
|
||||
SELECT COUNT(*)
|
||||
FROM inspection_findings finding
|
||||
WHERE finding.status = 'OPEN'
|
||||
AND finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||
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 "overdueControls",
|
||||
(
|
||||
SELECT COUNT(*) FROM inspection_findings
|
||||
WHERE status = 'OPEN'
|
||||
AND next_control_on BETWEEN
|
||||
SELECT COUNT(*)
|
||||
FROM inspection_findings finding
|
||||
WHERE finding.status = 'OPEN'
|
||||
AND finding.next_control_on BETWEEN
|
||||
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
|
||||
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[];
|
||||
|
||||
const recentAudit = (await manager.query(`
|
||||
const recentInspectorActivity = (await manager.query(`
|
||||
SELECT
|
||||
event.id,
|
||||
event.occurred_at AS "occurredAt",
|
||||
@@ -113,9 +143,25 @@ export class DashboardService {
|
||||
event.entity_type AS "entityType",
|
||||
event.entity_id AS "entityId"
|
||||
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
|
||||
LIMIT 6
|
||||
`)) as DashboardAuditItem[];
|
||||
LIMIT 8
|
||||
`)) as DashboardInspectorActivityItem[];
|
||||
|
||||
const upcomingControls = (await manager.query(`
|
||||
SELECT
|
||||
@@ -135,8 +181,14 @@ export class DashboardService {
|
||||
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
||||
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||
WHERE finding.status = 'OPEN'
|
||||
AND finding.company_response_received_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
|
||||
LIMIT 8
|
||||
`)) as DashboardUpcomingControl[];
|
||||
@@ -151,14 +203,15 @@ export class DashboardService {
|
||||
inactiveUsers: Number(counts?.inactiveUsers ?? 0),
|
||||
activeSessions: Number(counts?.activeSessions ?? 0),
|
||||
openFindings: Number(counts?.openFindings ?? 0),
|
||||
awaitingCompanyResponse: Number(counts?.awaitingCompanyResponse ?? 0),
|
||||
overdueCompanyResponses: Number(counts?.overdueCompanyResponses ?? 0),
|
||||
companyResponsesDueNext7Days: Number(counts?.companyResponsesDueNext7Days ?? 0),
|
||||
awaitingVerificationSchedule: Number(counts?.awaitingVerificationSchedule ?? 0),
|
||||
actsInFollowUp: Number(counts?.actsInFollowUp ?? 0),
|
||||
findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
|
||||
overdueControls: Number(counts?.overdueControls ?? 0),
|
||||
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
|
||||
reportsWorking: Number(counts?.reportsWorking ?? 0),
|
||||
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
|
||||
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
|
||||
},
|
||||
recentAudit,
|
||||
recentInspectorActivity,
|
||||
upcomingControls,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ export enum AssetVersionChangeType {
|
||||
CREATED = 'CREATED',
|
||||
UPDATED = 'UPDATED',
|
||||
CONTEXT_CHANGED = 'CONTEXT_CHANGED',
|
||||
FUNCTION_CHANGED = 'FUNCTION_CHANGED',
|
||||
STATUS_CHANGED = 'STATUS_CHANGED',
|
||||
OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED',
|
||||
REGISTRY_UPDATED = 'REGISTRY_UPDATED',
|
||||
|
||||
@@ -57,6 +57,9 @@ export class Asset extends TimestampedEntity {
|
||||
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
|
||||
inventoryFamilyId!: string | null;
|
||||
|
||||
@Column({ name: 'is_inventory_instance', type: 'boolean', default: false })
|
||||
isInventoryInstance!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
code!: string;
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ export enum AuditAction {
|
||||
ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED',
|
||||
ASSET_UPDATED = 'ASSET_UPDATED',
|
||||
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_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED',
|
||||
ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED',
|
||||
@@ -82,15 +85,24 @@ export enum AuditAction {
|
||||
INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED',
|
||||
INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED',
|
||||
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_REOPENED = 'INSPECTION_ACT_REOPENED',
|
||||
INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED',
|
||||
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED',
|
||||
INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED',
|
||||
INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED',
|
||||
INSPECTION_REPORT_UPDATED = 'INSPECTION_REPORT_UPDATED',
|
||||
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_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_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
|
||||
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
|
||||
|
||||
@@ -6,14 +6,8 @@ export { Role } from './role.entity';
|
||||
export { UserRole } from './user-role.entity';
|
||||
export { User, UserStatus } from './user.entity';
|
||||
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
|
||||
export {
|
||||
AssetVersion,
|
||||
AssetVersionChangeType,
|
||||
} from './asset-version.entity';
|
||||
export {
|
||||
AssetAttributeDataType,
|
||||
AssetAttributeDefinition,
|
||||
} from './asset-attribute-definition.entity';
|
||||
export { AssetVersion, AssetVersionChangeType } from './asset-version.entity';
|
||||
export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity';
|
||||
export { AssetAttributeValue } from './asset-attribute-value.entity';
|
||||
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.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 { AssetTypeParentRule } from './asset-type-parent-rule.entity';
|
||||
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
|
||||
export {
|
||||
AssetGeometry,
|
||||
AssetGeometrySource,
|
||||
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 { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity';
|
||||
export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity';
|
||||
export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity';
|
||||
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
|
||||
export { InspectionVisitMember } from './inspection-visit-member.entity';
|
||||
export {
|
||||
DocumentAnnualSequence,
|
||||
DocumentSequenceType,
|
||||
} from './document-annual-sequence.entity';
|
||||
export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
|
||||
export {
|
||||
InspectionAct,
|
||||
InspectionActStatus,
|
||||
InspectionActUrgency,
|
||||
InspectionDeadlineBasis,
|
||||
InspectionDeadlineDayType,
|
||||
} from './inspection-act.entity';
|
||||
export { InspectionActAsset } from './inspection-act-asset.entity';
|
||||
export {
|
||||
InspectionReport,
|
||||
InspectionReportPdfStatus,
|
||||
InspectionReportReviewStatus,
|
||||
InspectionReportStatus,
|
||||
InspectionReportWordStatus,
|
||||
} from './inspection-report.entity';
|
||||
export {
|
||||
InspectionActVersion,
|
||||
InspectionActVersionEvent,
|
||||
} from './inspection-act-version.entity';
|
||||
export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity';
|
||||
export {
|
||||
InspectionActResponsible,
|
||||
InspectionResponsibleAttendanceStatus,
|
||||
InspectionResponsibleDocumentType,
|
||||
} from './inspection-act-responsible.entity';
|
||||
export {
|
||||
InspectionActClosure,
|
||||
InspectionActUploadMode,
|
||||
} from './inspection-act-closure.entity';
|
||||
export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity';
|
||||
export {
|
||||
InspectionActSignature,
|
||||
InspectionActSignatureSource,
|
||||
@@ -95,21 +53,18 @@ export {
|
||||
InspectionActSignerType,
|
||||
InspectionCompanySignatureManifestation,
|
||||
} 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 { FindingCatalogItem } from './finding-catalog-item.entity';
|
||||
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
||||
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
|
||||
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
|
||||
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
|
||||
export {
|
||||
InspectionFinding,
|
||||
InspectionFindingResponseDueBasis,
|
||||
InspectionFindingStatus,
|
||||
} from './inspection-finding.entity';
|
||||
export {
|
||||
InspectionFindingVersion,
|
||||
InspectionFindingVersionEvent,
|
||||
} from './inspection-finding-version.entity';
|
||||
export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity';
|
||||
export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity';
|
||||
export {
|
||||
InspectionCommunicationChannel,
|
||||
InspectionCommunicationDirection,
|
||||
@@ -122,14 +77,8 @@ export {
|
||||
InspectionEvidenceSource,
|
||||
InspectionFindingEvidence,
|
||||
} from './inspection-finding-evidence.entity';
|
||||
export {
|
||||
InspectionFindingVerificationVisit,
|
||||
InspectionVerificationOutcome,
|
||||
} from './inspection-finding-verification-visit.entity';
|
||||
export {
|
||||
InspectionFindingVerificationEvent,
|
||||
InspectionFindingVerificationEventType,
|
||||
} from './inspection-finding-verification-event.entity';
|
||||
export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
|
||||
export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity';
|
||||
|
||||
import { AuditEvent } from './audit-event.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 { AssetVersion } from './asset-version.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 { InspectionVisitAsset } from './inspection-visit-asset.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 { InspectionActClosure } from './inspection-act-closure.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 { FindingCatalogItem } from './finding-catalog-item.entity';
|
||||
import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
|
||||
@@ -207,11 +155,6 @@ export const PHASE_A_ENTITIES = [
|
||||
AssetGeometry,
|
||||
AssetVersion,
|
||||
AssetMedia,
|
||||
SurveyCampaign,
|
||||
SurveyCampaignTarget,
|
||||
SurveyTargetReport,
|
||||
SurveyTargetReportMedia,
|
||||
SurveyTargetReportVersion,
|
||||
InspectionVisit,
|
||||
InspectionVisitAsset,
|
||||
InspectionVisitMember,
|
||||
@@ -223,6 +166,10 @@ export const PHASE_A_ENTITIES = [
|
||||
InspectionActResponsible,
|
||||
InspectionActClosure,
|
||||
InspectionActSignature,
|
||||
InspectionDeadlinePolicy,
|
||||
InspectionBusinessCalendarDay,
|
||||
InspectionReportFollowUp,
|
||||
SystemSmtpSettings,
|
||||
FindingCategory,
|
||||
FindingCatalogItem,
|
||||
FindingCatalogItemAssetType,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
export enum InspectionActVersionEvent {
|
||||
CREATED = 'CREATED',
|
||||
UPDATED = 'UPDATED',
|
||||
LOCKED = 'LOCKED',
|
||||
SEALED = 'SEALED',
|
||||
READY = 'READY',
|
||||
REOPENED = 'REOPENED',
|
||||
CLOSED = 'CLOSED',
|
||||
|
||||
@@ -3,12 +3,33 @@ import { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
export enum InspectionActStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
LOCKED = 'LOCKED',
|
||||
SEALED = 'SEALED',
|
||||
READY = 'READY',
|
||||
CLOSED = 'CLOSED',
|
||||
CANCELLED = 'CANCELLED',
|
||||
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' })
|
||||
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
|
||||
@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_occurred_at', ['occurredAt'])
|
||||
@Index('idx_inspection_acts_created_by', ['createdBy'])
|
||||
@Index('idx_inspection_acts_deadline', ['deadlineAt'])
|
||||
export class InspectionAct extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -29,7 +51,7 @@ export class InspectionAct extends TimestampedEntity {
|
||||
@Column({ name: 'act_number', type: 'integer' })
|
||||
actNumber!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 24 })
|
||||
@Column({ type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT })
|
||||
@@ -47,6 +69,39 @@ export class InspectionAct extends TimestampedEntity {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
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 })
|
||||
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 { TimestampedEntity } from './timestamped.entity';
|
||||
|
||||
/** @deprecated Los plazos de respuesta pasan al nivel Acta/Informe en F4. */
|
||||
export enum InspectionFindingResponseDueBasis {
|
||||
FINDING_DATE = 'FINDING_DATE',
|
||||
REPORT_NOTIFICATION = 'REPORT_NOTIFICATION',
|
||||
@@ -18,6 +19,7 @@ export enum InspectionFindingStatus {
|
||||
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
|
||||
@Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
|
||||
@Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
|
||||
@Index('idx_inspection_findings_recurrence_of', ['recurrenceOfFindingId'])
|
||||
export class InspectionFinding extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -61,9 +63,16 @@ export class InspectionFinding extends TimestampedEntity {
|
||||
@Column({ type: 'smallint', nullable: true })
|
||||
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 })
|
||||
correctionDueOn!: string | null;
|
||||
|
||||
// Campos legacy conservados temporalmente para migrar datos sin pérdida.
|
||||
@Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true })
|
||||
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';
|
||||
|
||||
export enum InspectionReportStatus {
|
||||
WORKING = 'WORKING',
|
||||
OFFICIALIZED = 'OFFICIALIZED',
|
||||
FROZEN = 'FROZEN',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
@@ -18,12 +20,6 @@ export enum InspectionReportWordStatus {
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
export enum InspectionReportReviewStatus {
|
||||
PENDING_REVIEW = 'PENDING_REVIEW',
|
||||
APPROVED = 'APPROVED',
|
||||
SIGNED = 'SIGNED',
|
||||
}
|
||||
|
||||
@Entity({ name: 'inspection_reports' })
|
||||
@Index('idx_inspection_reports_visit_id', ['visitId'])
|
||||
@Index('uq_inspection_reports_act', ['actId'], { unique: true })
|
||||
@@ -31,6 +27,7 @@ export enum InspectionReportReviewStatus {
|
||||
@Index('uq_inspection_reports_code', ['code'], { unique: true })
|
||||
@Index('idx_inspection_reports_generated_at', ['generatedAt'])
|
||||
@Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
|
||||
@Index('idx_inspection_reports_gedo_officialized_at', ['gedoOfficializedAt'])
|
||||
export class InspectionReport extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
@@ -47,12 +44,39 @@ export class InspectionReport extends TimestampedEntity {
|
||||
@Column({ name: 'report_number', type: 'integer' })
|
||||
reportNumber!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 24 })
|
||||
@Column({ type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN })
|
||||
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.WORKING })
|
||||
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 })
|
||||
pdfStatus!: InspectionReportPdfStatus;
|
||||
|
||||
@@ -80,27 +104,9 @@ export class InspectionReport extends TimestampedEntity {
|
||||
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
|
||||
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 })
|
||||
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 })
|
||||
title!: string;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export enum InspectionVisitStatus {
|
||||
@Index('idx_inspection_visits_status', ['status'])
|
||||
@Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId'])
|
||||
@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'])
|
||||
export class InspectionVisit extends TimestampedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -23,9 +23,6 @@ export class InspectionVisit extends TimestampedEntity {
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
code!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
objective!: string | null;
|
||||
|
||||
@@ -47,9 +44,6 @@ export class InspectionVisit extends TimestampedEntity {
|
||||
@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: 'actual_started_at', type: 'timestamptz', nullable: true })
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F51CleanManualInventory1790087400000 implements MigrationInterface {
|
||||
name = 'F51CleanManualInventory1790087400000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// F5.1 is an intentional clean-start cut. The deployment process creates a
|
||||
// full database backup before migrations, so old domain data is recovered
|
||||
// from that backup rather than by pretending a destructive migration can
|
||||
// reconstruct historical rows.
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
|
||||
|
||||
// Remove all operational/domain instances and every table that depends on
|
||||
// them (visits, acts, findings, reports, versions, media, relations, etc.).
|
||||
// Users/roles/permissions and technical configuration are deliberately not
|
||||
// part of this TRUNCATE.
|
||||
await queryRunner.query('TRUNCATE TABLE assets CASCADE');
|
||||
|
||||
// Imported territorial/source material must not silently repopulate or
|
||||
// influence the new manually curated structure.
|
||||
await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE');
|
||||
await queryRunner.query('TRUNCATE TABLE source_documents CASCADE');
|
||||
|
||||
// Start the classification ↔ finding applicability review from zero while
|
||||
// preserving both master catalogs themselves.
|
||||
await queryRunner.query('TRUNCATE TABLE finding_catalog_item_inventory_families');
|
||||
|
||||
// Explicitly clear audit history, including authentication/admin events
|
||||
// accumulated during development. New events continue to be recorded after
|
||||
// this migration.
|
||||
await queryRunner.query('TRUNCATE TABLE audit_events');
|
||||
|
||||
// Import/reconciliation tables can contain rows not connected to a current
|
||||
// Asset. Clear every asset_import_* data table without coupling this cut to
|
||||
// one historical import implementation.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE table_name text;
|
||||
BEGIN
|
||||
FOR table_name IN
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = current_schema()
|
||||
AND tablename LIKE 'asset_import_%'
|
||||
LOOP
|
||||
EXECUTE format('TRUNCATE TABLE %I CASCADE', table_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// F5.1 decouples the physical Inventory tree from Empresa. A structural
|
||||
// Asset may therefore inherit an Area while no operator has been assigned
|
||||
// yet. Keep the useful invariant that an operator can never exist without
|
||||
// an Area, but remove the old all-or-nothing pair requirement.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE asset_context_history
|
||||
DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE asset_context_history
|
||||
ADD CONSTRAINT chk_asset_context_history_context_pair
|
||||
CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL)
|
||||
`);
|
||||
|
||||
// Departamento becomes the real root of the physical Inventory tree.
|
||||
await queryRunner.query(`
|
||||
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
|
||||
SELECT 'departamento','Departamento','Departamento administrativo que contiene Áreas.',true,true,'GENERIC'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM asset_types WHERE lower(code)='departamento'
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE asset_types
|
||||
SET name='Departamento',
|
||||
description='Departamento administrativo que contiene Áreas.',
|
||||
can_be_root=true,
|
||||
is_active=true,
|
||||
operational_role='GENERIC',
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE lower(code)='departamento'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE asset_types
|
||||
SET can_be_root=false,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE lower(code)='area'
|
||||
`);
|
||||
|
||||
// Area has exactly one canonical structural parent kind: Departamento.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM asset_type_parent_rules rule
|
||||
USING asset_types child
|
||||
WHERE rule.child_type_id=child.id AND lower(child.code)='area'
|
||||
`);
|
||||
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)='area' AND lower(parent.code)='departamento'
|
||||
ON CONFLICT (child_type_id,parent_type_id) DO NOTHING
|
||||
`);
|
||||
|
||||
// Database-level guard: UI/API bugs cannot create an invalid physical tree.
|
||||
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','departamento') THEN
|
||||
IF NEW.parent_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Departamento son maestros raíz independientes';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF child_code NOT IN ('area','yacimiento','instalacion','subinstalacion') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La estructura requiere Departamento → Área → Yacimiento → Instalación → Subinstalación';
|
||||
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='area' AND parent_code<>'departamento')
|
||||
OR (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 inválida: Departamento → Área → Yacimiento → Instalación → Subinstalación';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
`);
|
||||
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()
|
||||
`);
|
||||
|
||||
const rows = (await queryRunner.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::integer FROM assets) AS assets,
|
||||
(SELECT COUNT(*)::integer FROM audit_events) AS audits,
|
||||
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families) AS applicability,
|
||||
(SELECT COUNT(*)::integer FROM asset_types WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true) AS departments,
|
||||
(SELECT COUNT(*)::integer
|
||||
FROM asset_type_parent_rules rule
|
||||
JOIN asset_types child ON child.id=rule.child_type_id
|
||||
JOIN asset_types parent ON parent.id=rule.parent_type_id
|
||||
WHERE lower(child.code)='area' AND lower(parent.code)='departamento') AS area_rules
|
||||
`)) as Array<{ assets: number; audits: number; applicability: number; departments: number; area_rules: number }>;
|
||||
const check = rows[0];
|
||||
if (!check || Number(check.assets) !== 0 || Number(check.audits) !== 0 || Number(check.applicability) !== 0
|
||||
|| Number(check.departments) !== 1 || Number(check.area_rules) !== 1) {
|
||||
throw new Error(`F5.1 clean-start verification failed: ${JSON.stringify(check ?? {})}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
throw new Error('F5.1 is an intentional destructive clean-start migration. Restore the pre-deploy database backup to recover previous data.');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -11,11 +12,15 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { InspectionActUrgency } from '../../database/entities';
|
||||
|
||||
export class CreateInspectionActDto {
|
||||
@IsISO8601({ strict: true })
|
||||
occurredAt!: string;
|
||||
|
||||
@IsEnum(InspectionActUrgency)
|
||||
urgency!: InspectionActUrgency;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -11,12 +12,17 @@ import {
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { InspectionActUrgency } from '../../database/entities';
|
||||
|
||||
export class UpdateInspectionActDto {
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
occurredAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(InspectionActUrgency)
|
||||
urgency?: InspectionActUrgency;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
DocumentSequenceType,
|
||||
InspectionAct,
|
||||
InspectionActStatus,
|
||||
InspectionActUrgency,
|
||||
InspectionDeadlineBasis,
|
||||
InspectionDeadlineDayType,
|
||||
InspectionActVersionEvent,
|
||||
InspectionVisit,
|
||||
InspectionVisitStatus,
|
||||
@@ -41,7 +44,6 @@ interface ActAsset {
|
||||
interface ActVisitSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: InspectionVisitStatus;
|
||||
actualStartedAt: Date | null;
|
||||
}
|
||||
@@ -72,6 +74,17 @@ export interface InspectionActListItem {
|
||||
title: string;
|
||||
summary: string;
|
||||
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;
|
||||
cancellationReason: string | null;
|
||||
closedAt: Date | null;
|
||||
@@ -137,7 +150,6 @@ export class InspectionActsService {
|
||||
act.code ILIKE ${search}
|
||||
OR act.title ILIKE ${search}
|
||||
OR visit.code ILIKE ${search}
|
||||
OR visit.title ILIKE ${search}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_act_assets search_link
|
||||
@@ -313,13 +325,16 @@ export class InspectionActsService {
|
||||
const occurredAt = new Date(dto.occurredAt);
|
||||
const actYear = await this.yearAtProjectTimezone(manager, occurredAt);
|
||||
const actNumber = await this.allocateNumber(manager, actYear);
|
||||
if (actNumber > 999999) {
|
||||
if (actNumber > 99999) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED',
|
||||
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({
|
||||
visitId,
|
||||
actYear,
|
||||
@@ -330,6 +345,17 @@ export class InspectionActsService {
|
||||
title: dto.title,
|
||||
summary: dto.summary,
|
||||
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,
|
||||
cancellationReason: null,
|
||||
createdBy: principal.userId,
|
||||
@@ -384,6 +410,7 @@ export class InspectionActsService {
|
||||
await this.assertVisitAssets(manager, visit.id, nextAssetIds);
|
||||
const before = await this.loadView(manager, id);
|
||||
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.summary !== undefined) act.summary = dto.summary;
|
||||
if (dto.observations !== undefined) act.observations = dto.observations;
|
||||
@@ -462,7 +489,6 @@ export class InspectionActsService {
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', visit.id,
|
||||
'code', visit.code,
|
||||
'title', visit.title,
|
||||
'status', visit.status,
|
||||
'actualStartedAt', visit.actual_started_at
|
||||
) AS visit,
|
||||
@@ -474,6 +500,17 @@ export class InspectionActsService {
|
||||
act.title,
|
||||
act.summary,
|
||||
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.cancellation_reason AS "cancellationReason",
|
||||
act.closed_at AS "closedAt",
|
||||
@@ -645,7 +682,7 @@ export class InspectionActsService {
|
||||
if (rows.length > 0) {
|
||||
throw new ConflictException({
|
||||
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) {
|
||||
throw new BadRequestException({
|
||||
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,
|
||||
'summary', act.summary,
|
||||
'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,
|
||||
'cancellationReason', act.cancellation_reason,
|
||||
'createdBy', act.created_by,
|
||||
@@ -765,7 +811,6 @@ export class InspectionActsService {
|
||||
'visit', JSONB_BUILD_OBJECT(
|
||||
'id', visit.id,
|
||||
'code', visit.code,
|
||||
'title', visit.title,
|
||||
'status', visit.status,
|
||||
'scopeAssetId', visit.scope_asset_id,
|
||||
'actualStartedAt', visit.actual_started_at
|
||||
@@ -804,6 +849,15 @@ export class InspectionActsService {
|
||||
title: act.title,
|
||||
summary: act.summary,
|
||||
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,
|
||||
cancellationReason: act.cancellationReason,
|
||||
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);
|
||||
}
|
||||
|
||||
@Post('ready')
|
||||
@Post('lock')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
ready(
|
||||
lock(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
@@ -58,16 +58,6 @@ export class InspectionClosingController {
|
||||
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')
|
||||
@RequirePermissions('inspection_closure.sign')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
@@ -109,9 +99,9 @@ export class InspectionClosingController {
|
||||
return this.closing.recordCompanyOutcome(actId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('close')
|
||||
@Post('seal')
|
||||
@RequirePermissions('inspection_closure.close')
|
||||
close(
|
||||
seal(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: CloseInspectionActDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.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 {
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
@@ -9,7 +14,12 @@ import { InspectionClosingService } from './inspection-closing.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, InspectionReportsModule],
|
||||
controllers: [InspectionClosingController, InspectionSignatureContentController],
|
||||
providers: [InspectionClosingService],
|
||||
controllers: [
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
CompanySignatureInviteController,
|
||||
PublicCompanySignatureController,
|
||||
],
|
||||
providers: [InspectionClosingService, CompanySignatureInviteService],
|
||||
})
|
||||
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 { DataSource } from 'typeorm';
|
||||
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
|
||||
import { FindingCatalogService } from './finding-catalog.service';
|
||||
|
||||
@Injectable()
|
||||
export class F3FindingCatalogResolverService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly legacyCatalog: FindingCatalogService,
|
||||
) {}
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
|
||||
const [asset] = await this.dataSource.query(`
|
||||
SELECT asset.id,asset.code,asset.name,
|
||||
type.code AS "typeCode",
|
||||
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
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||
WHERE asset.id=$1::uuid
|
||||
`, [assetId]) as Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
typeCode: string;
|
||||
familyId: string | null;
|
||||
familyCode: string | null;
|
||||
familyName: string | null;
|
||||
familyLevel: string | null;
|
||||
familyActive: boolean | null;
|
||||
}>;
|
||||
if (!asset) {
|
||||
throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
||||
}
|
||||
if (!asset.familyId) return this.legacyCatalog.listApplicableForAsset(assetId, query);
|
||||
|
||||
const 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 = [
|
||||
'mapping.inventory_family_id=$1::uuid',
|
||||
'item.is_active=true',
|
||||
'category.is_active=true',
|
||||
"lower(category.code)='f5model'",
|
||||
'merge_record.source_item_id IS NULL',
|
||||
];
|
||||
const params: unknown[] = [asset.familyId];
|
||||
@@ -83,15 +107,10 @@ export class F3FindingCatalogResolverService {
|
||||
level: asset.familyLevel,
|
||||
},
|
||||
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,
|
||||
items,
|
||||
other: {
|
||||
enabled: true,
|
||||
code: 'OTHER',
|
||||
label: 'OTROS',
|
||||
help: 'Usalo cuando el hallazgo no exista en la familia técnica. Se enviará una propuesta a revisión de oficina.',
|
||||
},
|
||||
other,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { CreateInspectionFindingDto } from './dto/create-inspection-finding.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 { 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 type { InspectionFindingView } from './inspection-findings.service';
|
||||
|
||||
@Controller('inspection-acts/:actId/findings')
|
||||
export class InspectionActFindingsController {
|
||||
@@ -43,18 +46,28 @@ export class InspectionActFindingsController {
|
||||
|
||||
@Controller('inspection-findings')
|
||||
export class InspectionFindingsController {
|
||||
constructor(private readonly findings: InspectionFindingsService) {}
|
||||
constructor(
|
||||
private readonly findings: InspectionFindingsService,
|
||||
private readonly worklist: InspectionFindingWorklistService,
|
||||
private readonly recurrence: FindingRecurrenceService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspection_findings.read')
|
||||
list(@Query() query: ListInspectionFindingsQueryDto) {
|
||||
return this.findings.listGlobal(query);
|
||||
return this.worklist.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('inspection_findings.read')
|
||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
return this.findings.getById(id);
|
||||
async get(
|
||||
@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')
|
||||
@@ -84,15 +97,4 @@ export class InspectionFindingsController {
|
||||
) {
|
||||
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 { FindingCatalogService } from './finding-catalog.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 {
|
||||
InspectionActFindingsController,
|
||||
InspectionFindingsController,
|
||||
@@ -22,6 +25,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
FindingCatalogController,
|
||||
InspectionActFindingsController,
|
||||
InspectionFindingsController,
|
||||
FindingRecurrenceController,
|
||||
InspectionFindingEvidenceController,
|
||||
InspectionFindingCommunicationsController,
|
||||
InspectionEvidenceContentController,
|
||||
@@ -31,6 +35,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
F3FindingCatalogResolverService,
|
||||
FindingCatalogMergeService,
|
||||
InspectionFindingsService,
|
||||
InspectionFindingWorklistService,
|
||||
FindingRecurrenceService,
|
||||
InspectionEvidenceService,
|
||||
],
|
||||
exports: [
|
||||
@@ -38,6 +44,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
|
||||
F3FindingCatalogResolverService,
|
||||
FindingCatalogMergeService,
|
||||
InspectionFindingsService,
|
||||
InspectionFindingWorklistService,
|
||||
FindingRecurrenceService,
|
||||
],
|
||||
})
|
||||
export class InspectionFindingsModule {}
|
||||
|
||||
@@ -62,7 +62,6 @@ interface FindingDocumentView {
|
||||
actStatus: InspectionActStatus;
|
||||
visitId: string;
|
||||
visitCode: string;
|
||||
visitTitle: string;
|
||||
visitStatus: InspectionVisitStatus;
|
||||
}
|
||||
|
||||
@@ -99,7 +98,6 @@ export interface InspectionFindingListItem {
|
||||
verificationVisit: {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: InspectionVisitStatus;
|
||||
plannedStartAt: Date | null;
|
||||
} | null;
|
||||
@@ -143,7 +141,6 @@ export interface FindingVerificationHistoryEvent {
|
||||
verificationVisit: {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: InspectionVisitStatus;
|
||||
plannedStartAt: Date | null;
|
||||
} | null;
|
||||
@@ -811,13 +808,11 @@ export class InspectionFindingsService {
|
||||
'actStatus', act.status,
|
||||
'visitId', visit.id,
|
||||
'visitCode', visit.code,
|
||||
'visitTitle', visit.title,
|
||||
'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,
|
||||
'title', verification_visit.title,
|
||||
'status', verification_visit.status,
|
||||
'plannedStartAt', verification_visit.planned_start_at
|
||||
) 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_categories category ON category.id = catalog.category_id
|
||||
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
|
||||
FROM inspection_finding_verification_visits verification_link
|
||||
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(
|
||||
'id', visit.id,
|
||||
'code', visit.code,
|
||||
'title', visit.title,
|
||||
'status', visit.status,
|
||||
'plannedStartAt', visit.planned_start_at
|
||||
) 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 { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
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 type { DeliveryRow } from './inspection-document-delivery.service';
|
||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
|
||||
import { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
|
||||
|
||||
@Controller('document-delivery')
|
||||
export class DocumentDeliveryController {
|
||||
constructor(private readonly delivery:InspectionDocumentDeliveryService){}
|
||||
@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('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);}
|
||||
constructor(
|
||||
private readonly delivery: InspectionDocumentDeliveryService,
|
||||
private readonly smtp: SmtpDeliveryService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
@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';
|
||||
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 { Transform } from 'class-transformer';
|
||||
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';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
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 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 asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
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[] {
|
||||
const prepared=asRecord(asRecord(snapshot).preparedSnapshot);
|
||||
const act=asRecord(prepared.act);
|
||||
const visit=asRecord(act.visit);
|
||||
const responsible=asRecord(prepared.responsible);
|
||||
const team=asArray(prepared.team);
|
||||
const assets=asArray(prepared.assets);
|
||||
const findings=asArray(prepared.findings);
|
||||
const signatures=asArray(snapshot.signatures);
|
||||
const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE');
|
||||
const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))];
|
||||
const 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 out:string[]=[
|
||||
const source = lockedSnapshot(snapshot);
|
||||
const locked = source.locked;
|
||||
const act = asRecord(locked.act);
|
||||
const inspection = asRecord(act.inspection ?? asRecord(act).visit);
|
||||
const responsible = asRecord(locked.responsible);
|
||||
const inventories = asArray(locked.inventories ?? locked.assets);
|
||||
const findings = asArray(locked.findings);
|
||||
const signatures = source.signatures;
|
||||
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE');
|
||||
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
|
||||
|
||||
const deadlineText = act.deadlineAt
|
||||
? `${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',
|
||||
'Documento automatico - diseno institucional pendiente de definicion',
|
||||
'',
|
||||
`Acta: ${text(act.code)}`,
|
||||
`Inspeccion: ${text(visit.code)}`,
|
||||
`Inspeccion: ${text(inspection.code)}`,
|
||||
`Fecha: ${date(act.occurredAt)}`,
|
||||
`Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`,
|
||||
`Area: ${areas.join(' / ') || 'Segun alcance del acta'}`,
|
||||
`Inspector/es: ${inspectors.join(' / ') || '-'}`,
|
||||
`Urgencia: ${urgencyLabel(act.urgency)}`,
|
||||
`Plazo: ${deadlineText}`,
|
||||
`Responsable empresa: ${text(responsible.fullName)}`,
|
||||
`Cargo: ${text(responsible.position)}`,
|
||||
'',
|
||||
'OBJETIVO', ...wrap(text(visit.objective)), '',
|
||||
'RESUMEN', ...wrap(text(act.summary)), '',
|
||||
'OBSERVACIONES', ...wrap(text(act.observations)), '',
|
||||
'ELEMENTOS INSPECCIONADOS',
|
||||
'RESUMEN',
|
||||
...wrap(text(act.summary)),
|
||||
'',
|
||||
'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)}`));
|
||||
out.push('', 'HALLAZGOS');
|
||||
if(!findings.length) out.push('Sin hallazgos registrados.');
|
||||
for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); }
|
||||
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)}`));
|
||||
|
||||
if (!inventories.length) out.push('-');
|
||||
for (const item of inventories) {
|
||||
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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 } {
|
||||
const all=lines(snapshot);
|
||||
const chunks:Array<string[]>=[];
|
||||
for(let i=0;i<all.length;i+=56) chunks.push(all.slice(i,i+56));
|
||||
if(!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
||||
const pageCount=chunks.length;
|
||||
const pageIds=Array.from({length:pageCount},(_,i)=>4+i*2);
|
||||
const contentIds=Array.from({length:pageCount},(_,i)=>5+i*2);
|
||||
const objects:Buffer[]=[];
|
||||
objects.push(objectBuffer(1,'<< /Type /Catalog /Pages 2 0 R >>'));
|
||||
objects.push(objectBuffer(2,`<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map(id=>`${id} 0 R`).join(' ')}] >>`));
|
||||
objects.push(objectBuffer(3,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
||||
chunks.forEach((chunk,index)=>{
|
||||
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');
|
||||
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 all = lines(snapshot);
|
||||
const chunks: Array<string[]> = [];
|
||||
for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
|
||||
if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
|
||||
|
||||
const pageCount = chunks.length;
|
||||
const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
|
||||
const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
|
||||
const objects: Buffer[] = [];
|
||||
objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
|
||||
objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
|
||||
objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
const content = chunk
|
||||
.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;
|
||||
for(const obj of objects){ offsets.push(pos); pos+=obj.length; }
|
||||
const xrefOffset=pos;
|
||||
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('');
|
||||
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') };
|
||||
|
||||
const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
|
||||
const offsets: number[] = [0];
|
||||
let position = header.length;
|
||||
for (const object of objects) {
|
||||
offsets.push(position);
|
||||
position += object.length;
|
||||
}
|
||||
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()
|
||||
export class InspectionActPdfService {
|
||||
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'); }
|
||||
private readonly root: string;
|
||||
|
||||
async ensure(actId:string):Promise<void>{
|
||||
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}>;
|
||||
if(!row)throw new NotFoundException({code:'INSPECTION_ACT_NOT_CLOSED',message:'El acta cerrada no está disponible'});
|
||||
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); }
|
||||
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 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}; }
|
||||
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'});}
|
||||
async ensure(actId: string): Promise<void> {
|
||||
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 { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
|
||||
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR';
|
||||
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR';
|
||||
|
||||
export interface DeliveryRow {
|
||||
id: string;
|
||||
@@ -40,21 +40,14 @@ export class InspectionDocumentDeliveryService {
|
||||
|
||||
async settings() {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT
|
||||
office_email AS "officeEmail",
|
||||
director_email AS "directorEmail",
|
||||
updated_at AS "updatedAt"
|
||||
SELECT office_email AS "officeEmail",updated_at AS "updatedAt"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id=1
|
||||
`) as Array<{
|
||||
officeEmail: string | null;
|
||||
directorEmail: string | null;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
`) as Array<{ officeEmail: string | null; updatedAt: Date }>;
|
||||
return {
|
||||
...row,
|
||||
smtpConfigured: this.smtp.configured(),
|
||||
mailFrom: this.config.get<string>('MAIL_FROM') ?? null,
|
||||
smtpConfigured: await this.smtp.configured(),
|
||||
mailFrom: await this.smtp.fromAddress() ?? this.config.get<string>('MAIL_FROM') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,9 +59,9 @@ export class InspectionDocumentDeliveryService {
|
||||
const before = await this.settings();
|
||||
await this.dataSource.query(`
|
||||
UPDATE institutional_delivery_settings
|
||||
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
|
||||
SET office_email=$1,updated_by=$2,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=1
|
||||
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]);
|
||||
`, [dto.officeEmail ?? null, principal.userId]);
|
||||
const after = await this.settings();
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
@@ -84,33 +77,23 @@ export class InspectionDocumentDeliveryService {
|
||||
async list() {
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
d.id,
|
||||
d.act_id AS "actId",
|
||||
d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",
|
||||
d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",
|
||||
d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",
|
||||
d.status,
|
||||
d.attempts,
|
||||
d.last_attempt_at AS "lastAttemptAt",
|
||||
d.sent_at AS "sentAt",
|
||||
d.provider_message_id AS "providerMessageId",
|
||||
d.last_error AS "lastError",
|
||||
d.created_at AS "createdAt",
|
||||
a.code AS "actCode",
|
||||
r.code AS "reportCode",
|
||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||
d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt",
|
||||
d.provider_message_id AS "providerMessageId",d.last_error AS "lastError",
|
||||
d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode",
|
||||
recipient.name AS "recipientAssetName",
|
||||
CASE
|
||||
WHEN recipient_user.id IS NULL THEN NULL
|
||||
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
|
||||
CASE WHEN recipient_user.id IS NULL THEN NULL
|
||||
ELSE btrim(concat_ws(' ',recipient_user.first_name,recipient_user.last_name))
|
||||
END AS "recipientUserName"
|
||||
FROM inspection_document_deliveries d
|
||||
JOIN inspection_acts a ON a.id=d.act_id
|
||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id
|
||||
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
|
||||
WHERE d.recipient_kind<>'DIRECTOR'
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 200
|
||||
`);
|
||||
@@ -154,11 +137,9 @@ export class InspectionDocumentDeliveryService {
|
||||
|
||||
async retryPending(principal: AuthPrincipal, request: RequestWithContext) {
|
||||
const rows = await this.dataSource.query(`
|
||||
SELECT id
|
||||
FROM inspection_document_deliveries
|
||||
WHERE status<>'SENT'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 100
|
||||
SELECT id FROM inspection_document_deliveries
|
||||
WHERE status<>'SENT' AND recipient_kind<>'DIRECTOR'
|
||||
ORDER BY created_at ASC LIMIT 100
|
||||
`) as Array<{ id: string }>;
|
||||
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined);
|
||||
return { processed: rows.length };
|
||||
@@ -166,10 +147,9 @@ export class InspectionDocumentDeliveryService {
|
||||
|
||||
private async ensureRows(actId: string, reportId: string | null) {
|
||||
const [settings] = await this.dataSource.query(`
|
||||
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
||||
SELECT office_email AS "officeEmail"
|
||||
FROM institutional_delivery_settings WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null }>;
|
||||
|
||||
const companies = await this.dataSource.query(`
|
||||
SELECT DISTINCT company.id,profile.notification_email AS email
|
||||
@@ -185,9 +165,7 @@ export class InspectionDocumentDeliveryService {
|
||||
`, [actId]) as Array<{ id: string; email: string | null }>;
|
||||
|
||||
const [inspector] = await this.dataSource.query(`
|
||||
SELECT
|
||||
inspector.id,
|
||||
inspector.email
|
||||
SELECT inspector.id,inspector.email
|
||||
FROM inspection_acts act
|
||||
JOIN inspection_visits visit ON visit.id=act.visit_id
|
||||
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
|
||||
@@ -196,52 +174,32 @@ export class InspectionDocumentDeliveryService {
|
||||
|
||||
for (const company of companies) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'ACT_PDF',
|
||||
recipientKind: 'COMPANY',
|
||||
recipientAssetId: company.id,
|
||||
recipientUserId: null,
|
||||
recipientKey: company.id,
|
||||
recipientEmail: company.email,
|
||||
actId,reportId,documentKind:'ACT_PDF',recipientKind:'COMPANY',
|
||||
recipientAssetId:company.id,recipientUserId:null,recipientKey:company.id,
|
||||
recipientEmail:company.email,
|
||||
});
|
||||
}
|
||||
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'ACT_PDF',
|
||||
recipientKind: 'OFFICE',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: null,
|
||||
recipientKey: '00000000-0000-0000-0000-000000000000',
|
||||
recipientEmail: settings?.officeEmail ?? null,
|
||||
actId,reportId,documentKind:'ACT_PDF',recipientKind:'OFFICE',
|
||||
recipientAssetId:null,recipientUserId:null,
|
||||
recipientKey:'00000000-0000-0000-0000-000000000000',
|
||||
recipientEmail:settings?.officeEmail ?? null,
|
||||
});
|
||||
|
||||
if (inspector) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'ACT_PDF',
|
||||
recipientKind: 'INSPECTOR',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: inspector.id,
|
||||
recipientKey: inspector.id,
|
||||
recipientEmail: inspector.email,
|
||||
});
|
||||
}
|
||||
|
||||
if (reportId) {
|
||||
await this.upsertRow({
|
||||
actId,
|
||||
reportId,
|
||||
documentKind: 'REPORT_WORD',
|
||||
recipientKind: 'DIRECTOR',
|
||||
recipientAssetId: null,
|
||||
recipientUserId: null,
|
||||
recipientKey: '00000000-0000-0000-0000-000000000000',
|
||||
recipientEmail: settings?.directorEmail ?? null,
|
||||
actId,reportId,documentKind:'ACT_PDF',recipientKind:'INSPECTOR',
|
||||
recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
|
||||
recipientEmail:inspector.email,
|
||||
});
|
||||
if (reportId) {
|
||||
await this.upsertRow({
|
||||
actId,reportId,documentKind:'REPORT_WORD',recipientKind:'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_email=CASE
|
||||
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
|
||||
ELSE EXCLUDED.recipient_email
|
||||
END,
|
||||
ELSE EXCLUDED.recipient_email END,
|
||||
status=CASE
|
||||
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
|
||||
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
|
||||
ELSE inspection_document_deliveries.status
|
||||
END,
|
||||
ELSE inspection_document_deliveries.status END,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
`, [
|
||||
input.actId,
|
||||
input.reportId,
|
||||
input.documentKind,
|
||||
input.recipientKind,
|
||||
input.recipientAssetId,
|
||||
input.recipientUserId,
|
||||
input.recipientKey,
|
||||
input.recipientEmail,
|
||||
initialStatus,
|
||||
input.actId,input.reportId,input.documentKind,input.recipientKind,
|
||||
input.recipientAssetId,input.recipientUserId,input.recipientKey,input.recipientEmail,initialStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
|
||||
return this.dataSource.query(`
|
||||
SELECT
|
||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||
@@ -299,15 +247,14 @@ export class InspectionDocumentDeliveryService {
|
||||
FROM inspection_document_deliveries d
|
||||
JOIN inspection_acts a ON a.id=d.act_id
|
||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||
WHERE d.act_id=$1
|
||||
WHERE d.act_id=$1 AND d.recipient_kind<>'DIRECTOR'
|
||||
ORDER BY d.created_at
|
||||
`, [actId]) as Promise<DeliveryRow[]>;
|
||||
}
|
||||
|
||||
private async load(id: string): Promise<DeliveryRow> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT
|
||||
d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
|
||||
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
|
||||
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
|
||||
d.recipient_email AS "recipientEmail",d.status,d.attempts,
|
||||
@@ -315,12 +262,11 @@ export class InspectionDocumentDeliveryService {
|
||||
FROM inspection_document_deliveries d
|
||||
JOIN inspection_acts a ON a.id=d.act_id
|
||||
LEFT JOIN inspection_reports r ON r.id=d.report_id
|
||||
WHERE d.id=$1
|
||||
WHERE d.id=$1 AND d.recipient_kind<>'DIRECTOR'
|
||||
`, [id]) as DeliveryRow[];
|
||||
if (!row) {
|
||||
throw new NotFoundException({
|
||||
code: 'DOCUMENT_DELIVERY_NOT_FOUND',
|
||||
message: 'Entrega documental no encontrada',
|
||||
code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada',
|
||||
});
|
||||
}
|
||||
return row;
|
||||
@@ -330,47 +276,37 @@ export class InspectionDocumentDeliveryService {
|
||||
let email: string | null = null;
|
||||
if (row.recipientKind === 'COMPANY' && row.recipientAssetId) {
|
||||
const [company] = await this.dataSource.query(`
|
||||
SELECT notification_email AS email
|
||||
FROM organization_profiles
|
||||
WHERE asset_id=$1
|
||||
SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1
|
||||
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
|
||||
email = company?.email ?? null;
|
||||
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
|
||||
const [inspector] = await this.dataSource.query(`
|
||||
SELECT email
|
||||
FROM users
|
||||
WHERE id=$1 AND is_active=true
|
||||
SELECT email FROM users WHERE id=$1 AND is_active=true
|
||||
`, [row.recipientUserId]) as Array<{ email: string | null }>;
|
||||
email = inspector?.email ?? null;
|
||||
} else {
|
||||
const [settings] = await this.dataSource.query(`
|
||||
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
|
||||
FROM institutional_delivery_settings
|
||||
WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
|
||||
email = row.recipientKind === 'OFFICE'
|
||||
? settings?.officeEmail ?? null
|
||||
: settings?.directorEmail ?? null;
|
||||
SELECT office_email AS "officeEmail" FROM institutional_delivery_settings WHERE id=1
|
||||
`) as Array<{ officeEmail: string | null }>;
|
||||
email = settings?.officeEmail ?? null;
|
||||
}
|
||||
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET recipient_email=$2,
|
||||
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
|
||||
last_error=NULL,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1 AND status<>'SENT'
|
||||
`, [row.id, email]);
|
||||
`, [row.id,email]);
|
||||
}
|
||||
|
||||
private async attempt(row: DeliveryRow) {
|
||||
if (row.status === 'SENT') return;
|
||||
if (!row.recipientEmail) {
|
||||
await this.setStatus(row.id, 'WAITING_RECIPIENT', 'Destinatario no configurado');
|
||||
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
|
||||
return;
|
||||
}
|
||||
if (!this.smtp.configured()) {
|
||||
await this.setStatus(row.id, 'WAITING_TRANSPORT', 'SMTP no configurado');
|
||||
if (!await this.smtp.configured()) {
|
||||
await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,28 +315,18 @@ export class InspectionDocumentDeliveryService {
|
||||
if (row.documentKind === 'ACT_PDF') {
|
||||
await this.pdf.ensure(row.actId);
|
||||
const file = await this.pdf.content(row.actId);
|
||||
attachment = {
|
||||
filename: file.originalName,
|
||||
mimeType: file.mimeType,
|
||||
content: file.buffer,
|
||||
};
|
||||
attachment = { filename:file.originalName,mimeType:file.mimeType,content:file.buffer };
|
||||
} else {
|
||||
if (!row.reportId) throw new Error('Informe no vinculado');
|
||||
await this.word.ensure(row.reportId);
|
||||
const file = await this.word.content(row.reportId);
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
attachment = {
|
||||
filename: file.originalName,
|
||||
mimeType: file.mimeType,
|
||||
content: await readFile(file.filePath),
|
||||
filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
await this.setStatus(
|
||||
row.id,
|
||||
'WAITING_ARTIFACT',
|
||||
error instanceof Error ? error.message : 'Documento no disponible',
|
||||
);
|
||||
await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error ? error.message : 'Documento no disponible');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -409,62 +335,41 @@ export class InspectionDocumentDeliveryService {
|
||||
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
|
||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`, [row.id]);
|
||||
`,[row.id]);
|
||||
|
||||
try {
|
||||
const label = row.documentKind === 'ACT_PDF'
|
||||
? `Acta ${row.actCode}`
|
||||
: `Informe ${row.reportCode ?? ''}`;
|
||||
const text = row.documentKind === 'REPORT_WORD'
|
||||
? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.`
|
||||
? `Se adjunta el INF editable ${row.reportCode ?? ''} para su revisión y preparación antes de cargarlo en GEDO.`
|
||||
: row.recipientKind === 'INSPECTOR'
|
||||
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.`
|
||||
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`;
|
||||
? `Se adjunta copia del acta sellada e inmutable ${row.actCode} correspondiente a tu inspección.`
|
||||
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
|
||||
const sent = await this.smtp.send({
|
||||
to: row.recipientEmail,
|
||||
subject: `DH Inspección · ${label}`,
|
||||
text,
|
||||
attachment,
|
||||
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
|
||||
});
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
|
||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`, [row.id, sent.messageId]);
|
||||
last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1
|
||||
`,[row.id,sent.messageId]);
|
||||
await this.audit.record({
|
||||
action: AuditAction.DOCUMENT_DELIVERY_SENT,
|
||||
entityType: 'inspection_document_delivery',
|
||||
entityId: row.id,
|
||||
source: AuditSource.SYSTEM,
|
||||
actorUserId: null,
|
||||
actorUsername: null,
|
||||
requestId: null,
|
||||
ip: null,
|
||||
userAgent: null,
|
||||
beforeData: null,
|
||||
afterData: {
|
||||
recipientKind: row.recipientKind,
|
||||
recipientUserId: row.recipientUserId,
|
||||
documentKind: row.documentKind,
|
||||
status: 'SENT',
|
||||
},
|
||||
metadata: { actId: row.actId, reportId: row.reportId },
|
||||
action:AuditAction.DOCUMENT_DELIVERY_SENT,
|
||||
entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM,
|
||||
actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null,
|
||||
afterData:{recipientKind:row.recipientKind,recipientUserId:row.recipientUserId,documentKind:row.documentKind,status:'SENT'},
|
||||
metadata:{actId:row.actId,reportId:row.reportId},
|
||||
});
|
||||
} catch (error) {
|
||||
await this.setStatus(
|
||||
row.id,
|
||||
'FAILED',
|
||||
error instanceof Error ? error.message : 'Error de entrega',
|
||||
);
|
||||
await this.setStatus(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(`
|
||||
UPDATE inspection_document_deliveries
|
||||
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`, [id, status, error.slice(0, 500)]);
|
||||
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1
|
||||
`,[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;
|
||||
frozenSha256: string;
|
||||
frozenSnapshot: Record<string, unknown>;
|
||||
executiveSummary?: string | null;
|
||||
reportDescription?: string | null;
|
||||
}
|
||||
|
||||
const crcTable = (() => {
|
||||
@@ -56,8 +58,8 @@ function text(value: unknown, fallback = '—'): string {
|
||||
}
|
||||
|
||||
function isoDate(value: unknown): string {
|
||||
const date = new Date(String(value ?? ''));
|
||||
return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—';
|
||||
const parsed = new Date(String(value ?? ''));
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '—';
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
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 {
|
||||
const report = asRecord(input.frozenSnapshot.report);
|
||||
const actClosure = asRecord(input.frozenSnapshot.actClosure);
|
||||
const prepared = asRecord(actClosure.preparedSnapshot);
|
||||
const act = asRecord(prepared.act);
|
||||
const visit = asRecord(act.visit);
|
||||
const responsible = asRecord(prepared.responsible);
|
||||
const team = asArray(prepared.team);
|
||||
const assets = asArray(prepared.assets);
|
||||
const findings = asArray(prepared.findings);
|
||||
const signatures = asArray(actClosure.signatures);
|
||||
const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))];
|
||||
const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))];
|
||||
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 findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]);
|
||||
const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]);
|
||||
const snapshot = f4Snapshot(input);
|
||||
const findingRows = snapshot.findings.map((item) => [
|
||||
text(item.code),
|
||||
text(item.title),
|
||||
text(item.description),
|
||||
item.isRecurrence ? `Sí${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
|
||||
item.severity == null ? '—' : `${text(item.severity)}/10`,
|
||||
]);
|
||||
const inventoryRows = snapshot.inventories.map((item) => [
|
||||
text(item.code),
|
||||
text(item.name),
|
||||
text(item.typeName ?? item.typeCode),
|
||||
]);
|
||||
const signatureRows = snapshot.signatures.map((item) => [
|
||||
text(item.signerType),
|
||||
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 = [
|
||||
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('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('Acta', text(report.actCode ?? act.code)),
|
||||
labelValue('Inspección', text(report.visitCode ?? visit.code)),
|
||||
labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'),
|
||||
labelValue('Área', areas.join(' · ') || 'Según alcance del acta'),
|
||||
labelValue('Inspector/es', inspectorNames.join(' · ') || '—'),
|
||||
labelValue('Responsable de empresa', text(responsible.fullName)),
|
||||
labelValue('Cargo', text(responsible.position)),
|
||||
paragraph('Datos de la visita', 'Heading1'),
|
||||
labelValue('Objetivo', text(visit.objective)),
|
||||
labelValue('Fecha de inspección', isoDate(act.occurredAt)),
|
||||
labelValue('Resumen', text(act.summary)),
|
||||
labelValue('Observaciones', text(act.observations)),
|
||||
paragraph('Elementos inspeccionados', 'Heading1'),
|
||||
assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'),
|
||||
paragraph('Resumen ejecutivo', 'Heading1'),
|
||||
paragraph(executive),
|
||||
paragraph('Descripción / análisis técnico', 'Heading1'),
|
||||
paragraph(description),
|
||||
paragraph('Acta fuente · contenido inmutable', 'Heading1'),
|
||||
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
|
||||
labelValue('Resumen del Acta', text(snapshot.act.summary)),
|
||||
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
|
||||
labelValue('Responsable de empresa', text(snapshot.responsible.fullName)),
|
||||
labelValue('Cargo', text(snapshot.responsible.position)),
|
||||
paragraph('Inventario inspeccionado', 'Heading1'),
|
||||
inventoryRows.length
|
||||
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
|
||||
: paragraph('No se registraron elementos de Inventario en el Acta.'),
|
||||
paragraph('Hallazgos', 'Heading1'),
|
||||
findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'),
|
||||
paragraph('Firmas y constancias', 'Heading1'),
|
||||
signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'),
|
||||
paragraph('Integridad documental', 'Heading1'),
|
||||
labelValue('Hash del informe', input.frozenSha256),
|
||||
labelValue('Hash de cierre del acta', text(report.actClosureSha256)),
|
||||
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.'),
|
||||
findingRows.length
|
||||
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
|
||||
: paragraph('El Acta no contiene Hallazgos.'),
|
||||
paragraph('Firmas y manifestaciones', 'Heading1'),
|
||||
signatureRows.length
|
||||
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
|
||||
: 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('');
|
||||
|
||||
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(0, 28);
|
||||
locals.push(local, name, entry.data);
|
||||
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
@@ -168,6 +215,7 @@ function buildZip(entries: ZipEntry[]): Buffer {
|
||||
centrals.push(central, name);
|
||||
offset += local.length + name.length + entry.data.length;
|
||||
}
|
||||
|
||||
const centralData = Buffer.concat(centrals);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
|
||||
@@ -12,6 +12,8 @@ interface WordRow {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
executiveSummary: string | null;
|
||||
reportDescription: string | null;
|
||||
generatedAt: Date;
|
||||
frozenSha256: string;
|
||||
frozenSnapshot: Record<string, unknown>;
|
||||
@@ -29,7 +31,8 @@ export class InspectionReportWordService {
|
||||
private readonly root: string;
|
||||
|
||||
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');
|
||||
this.root = resolve(configured);
|
||||
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> {
|
||||
const row = await this.load(reportId);
|
||||
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 {
|
||||
const built = buildInspectionReportWord({
|
||||
@@ -47,40 +54,51 @@ export class InspectionReportWordService {
|
||||
generatedAt: new Date(row.generatedAt),
|
||||
frozenSha256: row.frozenSha256,
|
||||
frozenSnapshot: row.frozenSnapshot,
|
||||
executiveSummary: row.executiveSummary,
|
||||
reportDescription: row.reportDescription,
|
||||
});
|
||||
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 filePath = resolve(this.root, storedName);
|
||||
await writeFile(filePath, built.buffer, { mode: 0o600 });
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_reports
|
||||
SET word_status = 'READY',
|
||||
word_original_name = $2,
|
||||
word_stored_name = $3,
|
||||
word_mime_type = $4,
|
||||
word_size_bytes = $5,
|
||||
word_sha256 = $6,
|
||||
word_generated_at = CURRENT_TIMESTAMP,
|
||||
word_error = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
SET word_status='READY',
|
||||
word_original_name=$2,
|
||||
word_stored_name=$3,
|
||||
word_mime_type=$4,
|
||||
word_size_bytes=$5,
|
||||
word_sha256=$6,
|
||||
word_generated_at=CURRENT_TIMESTAMP,
|
||||
word_error=NULL,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]);
|
||||
await this.ensureInitialRevision(await this.load(row.id));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_reports
|
||||
SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
SET word_status='FAILED',word_error=$2,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`, [row.id, message]).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||
const row = await this.load(reportId);
|
||||
if (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' });
|
||||
if (
|
||||
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);
|
||||
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
|
||||
@@ -89,40 +107,78 @@ export class InspectionReportWordService {
|
||||
const buffer = await readFile(filePath);
|
||||
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
||||
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> {
|
||||
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(`
|
||||
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
|
||||
) 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))
|
||||
ON CONFLICT (report_id, revision_number) DO NOTHING
|
||||
`, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]);
|
||||
report_id,revision_number,source,original_name,stored_name,mime_type,
|
||||
size_bytes,sha256,change_summary,created_by,created_at
|
||||
) VALUES (
|
||||
$1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial editable',$7,
|
||||
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(`
|
||||
UPDATE inspection_reports
|
||||
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
SET current_revision_number=GREATEST(current_revision_number,1),updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`, [row.id]);
|
||||
}
|
||||
|
||||
private async load(reportId: string): Promise<WordRow> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256",
|
||||
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
|
||||
SELECT id,code,title,
|
||||
executive_summary AS "executiveSummary",
|
||||
report_description AS "reportDescription",
|
||||
generated_at AS "generatedAt",
|
||||
frozen_sha256 AS "frozenSha256",
|
||||
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[];
|
||||
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;
|
||||
}
|
||||
|
||||
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 { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { Response } from 'express';
|
||||
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 { 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 { InspectionReportWordService } from './inspection-report-word.service';
|
||||
|
||||
@Controller('inspection-reports')
|
||||
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()
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
@@ -42,6 +68,65 @@ export class InspectionReportsController {
|
||||
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')
|
||||
@RequirePermissions('inspection_reports.read')
|
||||
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 { DocumentDeliveryController } from './document-delivery.controller';
|
||||
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 { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
|
||||
import { InspectionReportReviewService } from './inspection-report-review.service';
|
||||
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||
import { InspectionReportsService } from './inspection-reports.service';
|
||||
@@ -15,18 +16,18 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||
controllers: [
|
||||
InspectionReportsController,
|
||||
InspectionActReportController,
|
||||
InspectionDeadlineAdminController,
|
||||
DocumentDeliveryController,
|
||||
InspectionReportReviewController,
|
||||
InspectionReportRevisionContentController,
|
||||
],
|
||||
providers: [
|
||||
InspectionReportsService,
|
||||
InspectionReportWorkflowService,
|
||||
InspectionReportWordService,
|
||||
InspectionActPdfService,
|
||||
InspectionDeadlineAdminService,
|
||||
InspectionDocumentDeliveryService,
|
||||
SmtpDeliveryService,
|
||||
InspectionReportReviewService,
|
||||
],
|
||||
exports: [InspectionReportsService],
|
||||
exports: [InspectionReportsService, SmtpDeliveryService],
|
||||
})
|
||||
export class InspectionReportsModule {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user