Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ff64182c | ||
|
|
a9e300d6a4 | ||
|
|
143db53552 | ||
|
|
d9fbd95da3 | ||
|
|
b744a77f49 | ||
|
|
082354320b | ||
|
|
71dda05c20 | ||
|
|
b88855860d | ||
|
|
ec84f42a74 | ||
|
|
b859278ad0 | ||
|
|
32fc5bc60d | ||
|
|
7c7eff3f93 | ||
|
|
7e87306c9d | ||
|
|
88906e827b | ||
|
|
2aa4fd21b1 | ||
|
|
3089dd3724 | ||
|
|
d412399d13 | ||
|
|
c14582e92c | ||
|
|
c10248be2c | ||
|
|
7217c11e8b | ||
|
|
95012cf373 | ||
|
|
fc8c3ba7ef | ||
|
|
95c5f4d0ca | ||
|
|
ca36140255 | ||
|
|
ed3c2c295a | ||
|
|
ea46be666f | ||
|
|
147e26b887 | ||
|
|
9d0ca7ebd8 | ||
|
|
f23f29b546 | ||
|
|
c2c917e440 | ||
|
|
51f476ce3e | ||
|
|
0a1c382254 | ||
|
|
e3231c5d04 | ||
|
|
326cf6681e | ||
|
|
5986474a35 | ||
|
|
f33c2b8df8 | ||
|
|
4d7f5f7df4 | ||
|
|
6e0adb7a07 | ||
|
|
90098d1fd5 | ||
|
|
3c838942c4 | ||
|
|
548d00662d | ||
|
|
729b90bfbf | ||
|
|
1b5cdbf57b | ||
|
|
a98dfb955d | ||
|
|
99f068b416 | ||
|
|
718e08be13 | ||
|
|
b88147d376 | ||
|
|
439809abc7 | ||
|
|
98b9af7344 | ||
|
|
18e16c2ec2 | ||
|
|
cc75c76860 | ||
|
|
474e8d4310 | ||
|
|
06923d2849 | ||
|
|
e5c71a0c16 | ||
|
|
c1f51eb81f | ||
|
|
68d1af63ff | ||
|
|
fe6be39302 | ||
|
|
72a4f70177 | ||
|
|
a98c9020b0 | ||
|
|
be543de927 | ||
|
|
d2e82719f6 | ||
|
|
fb6b2388c3 | ||
|
|
583fd58e1c | ||
|
|
67928b48f8 | ||
|
|
0e959708cf | ||
|
|
8bee2c852a | ||
|
|
0a9cfdffb1 | ||
|
|
dde3f32021 | ||
|
|
7ae79af595 | ||
|
|
f2754d87ed | ||
|
|
dea9358451 | ||
|
|
9fffafc447 | ||
|
|
466fb29089 | ||
|
|
b23d7736c0 | ||
|
|
5b1593ed78 | ||
|
|
ff7706497d | ||
|
|
cb002c60f9 | ||
|
|
74b91d0c8a | ||
|
|
53c8f2b4ab | ||
|
|
b7baea652b | ||
|
|
9e6d9e45cb | ||
|
|
82b587ea6a | ||
|
|
88e7e8de65 | ||
|
|
b265769047 | ||
|
|
9a40f5cbd0 | ||
|
|
ce579d1667 | ||
|
|
bc6d4604e3 | ||
|
|
5c69892815 | ||
|
|
c1eeb5f078 | ||
|
|
324a73d35c | ||
|
|
97f34a471f | ||
|
|
a5d954b4c7 | ||
|
|
a80ef4477c | ||
|
|
b21810f166 | ||
|
|
64486f40f6 | ||
|
|
a407bf46c5 | ||
|
|
bfb7eed608 | ||
|
|
c74fd80620 | ||
|
|
9bd1adbdb5 | ||
|
|
cc8d650aa4 | ||
|
|
9d0d1f33ce | ||
|
|
e5f1615665 | ||
|
|
7be5d89204 | ||
|
|
ce2a85a26a | ||
|
|
20ffa9e4b3 | ||
|
|
509bc0cde1 | ||
|
|
e549a44ae5 | ||
|
|
1dd424698a | ||
|
|
d9cdb48a26 | ||
|
|
bec92f0c6d | ||
|
|
3dab0938d6 | ||
|
|
f93e8240ad | ||
|
|
257ee20148 | ||
|
|
264ab70214 | ||
|
|
3903ae22e6 | ||
|
|
c454eb3255 | ||
|
|
a7cc9e0886 | ||
|
|
7d475cdc6f | ||
|
|
8b6608ecae | ||
|
|
5b237a4d4e | ||
|
|
16ca67cb76 | ||
|
|
ca1fddc834 | ||
|
|
b713be4d15 | ||
|
|
20ecbb2872 | ||
|
|
62f612dd24 | ||
|
|
fc978ad74e | ||
|
|
4f1f26ad93 | ||
|
|
ec8a9be99e | ||
|
|
5738306a14 | ||
|
|
33c0c6b8f6 | ||
|
|
44f6002869 | ||
|
|
517f934d5e | ||
|
|
e4acb7bebf | ||
|
|
f7145427c0 | ||
|
|
433220ee3a | ||
|
|
ba38dc7dda | ||
|
|
51ace362d7 | ||
|
|
3a149691e2 | ||
|
|
294abc4cf3 | ||
|
|
90443b3961 | ||
|
|
60c6d97f1a | ||
|
|
ae7326f20a | ||
|
|
5a9c4ed5ef | ||
|
|
0959e85487 | ||
|
|
a4fad611d4 | ||
|
|
d28186a567 | ||
|
|
25a63cd3d4 | ||
|
|
3d8ace5529 | ||
|
|
47203ff0da | ||
|
|
b701becab7 | ||
|
|
9983201de7 | ||
|
|
4fc05b73b1 | ||
|
|
40cfeff42e | ||
|
|
e3f89f4d5a | ||
|
|
7284c252ff | ||
|
|
7baf306a23 | ||
|
|
27a51c2fe2 | ||
|
|
b326eb737d | ||
|
|
6a5db887c0 | ||
|
|
baf63a861c | ||
|
|
d61e4ad04d | ||
|
|
37885c0454 | ||
|
|
b6707cef43 | ||
|
|
74fc08bf24 | ||
|
|
ad7ec2ca2b | ||
|
|
d938db7913 | ||
|
|
0f4bb13fc7 | ||
|
|
f0db23bc9b | ||
|
|
0b0851d9ec | ||
|
|
7338f277ad | ||
|
|
3d83545033 | ||
|
|
2b87e07fd2 | ||
|
|
35d4630581 | ||
|
|
1dc3282055 | ||
|
|
7875fea3ca | ||
|
|
0d5bdd25ce | ||
|
|
a75e9bdcb0 | ||
|
|
3027e9a8c8 | ||
|
|
09a190532c | ||
|
|
8a8ab47453 |
@@ -1,31 +1,36 @@
|
||||
name: Android APK
|
||||
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
|
||||
name: Android CI / RC
|
||||
# F6.1 presentation barrier: lint + real tests + debug artifact + release compile.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'feature/f2-2*'
|
||||
- 'feature/f2-3*'
|
||||
- 'feature/f2-4*'
|
||||
- 'feature/f3-1*'
|
||||
- 'feature/f3-2*'
|
||||
- 'main'
|
||||
- 'release/f6-1*'
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- 'api-v3/src/**'
|
||||
- '.github/workflows/android.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- 'api-v3/src/auth/**'
|
||||
- 'api-v3/src/**'
|
||||
- '.github/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dhv2-android-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-debug-apk:
|
||||
android:
|
||||
name: Android · lint, tests, debug APK, release compile
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -47,18 +52,92 @@ jobs:
|
||||
with:
|
||||
gradle-version: '8.13'
|
||||
|
||||
- name: Assemble debug
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:assembleDebug
|
||||
- name: Validate mobile security and identity contract
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
|
||||
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
|
||||
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
|
||||
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
|
||||
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
|
||||
|
||||
- name: Unit tests
|
||||
- name: Android lint
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:lintDebug
|
||||
|
||||
- name: Print complete lint failures
|
||||
if: failure()
|
||||
run: |
|
||||
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
|
||||
if [ -f "$report" ]; then
|
||||
echo '========== ANDROID LINT =========='
|
||||
cat "$report"
|
||||
fi
|
||||
|
||||
- name: Android unit tests
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:testDebugUnitTest
|
||||
|
||||
- name: Upload APK
|
||||
- name: Require real unit-test results
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
test_dir="android-app/app/build/test-results/testDebugUnitTest"
|
||||
test -d "$test_dir"
|
||||
total="$(grep -h -oE '<testsuite[^>]+tests="[0-9]+"' "$test_dir"/TEST-*.xml 2>/dev/null | sed -E 's/.*tests="([0-9]+)"/\1/' | awk '{sum += $1} END {print sum + 0}')"
|
||||
test "$total" -gt 0
|
||||
echo "Android unit tests discovered: $total"
|
||||
|
||||
- name: Assemble debug APK
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:assembleDebug
|
||||
|
||||
- name: Compile unsigned release variant
|
||||
working-directory: android-app
|
||||
run: gradle --no-daemon :app:assembleRelease
|
||||
|
||||
- name: Package RC artifact and checksum
|
||||
id: package
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)"
|
||||
code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)"
|
||||
test -n "$version"
|
||||
test -n "$code"
|
||||
short_sha="${GITHUB_SHA::12}"
|
||||
mkdir -p android-app/dist
|
||||
apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk"
|
||||
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
|
||||
sha256sum "$apk" > "${apk}.sha256"
|
||||
{
|
||||
echo "phase=F6.1"
|
||||
echo "version=$version"
|
||||
echo "versionCode=$code"
|
||||
echo "commit=$GITHUB_SHA"
|
||||
echo "artifact=$(basename "$apk")"
|
||||
echo "applicationId=com.korexlabs.dhinspeccion.debug"
|
||||
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
|
||||
echo "channel=DEBUG_RC"
|
||||
} > android-app/dist/release-metadata.txt
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "version_code=$code" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload debug RC
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: DH-Inspeccion-F3.2-0.12.0-debug
|
||||
path: android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
|
||||
path: android-app/dist/*
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Android diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-diagnostics-${{ github.sha }}
|
||||
path: |
|
||||
android-app/app/build/reports/lint-results-debug.html
|
||||
android-app/app/build/reports/tests/testDebugUnitTest/**
|
||||
android-app/app/build/test-results/testDebugUnitTest/**
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
+264
-1
@@ -62,9 +62,272 @@ 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: VPS-equivalent isolated API preflight
|
||||
- name: Rehearse migrations and real API startup on clean PostGIS
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cleanup() {
|
||||
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
|
||||
docker compose --env-file .env.example up -d db
|
||||
|
||||
# Historical production reset is a one-shot migration that expects the
|
||||
# production admin. Prove the clean chain reaches that exact guard,
|
||||
# mark only that historical reset as applied, then continue the chain.
|
||||
bootstrap_log="$(mktemp)"
|
||||
set +e
|
||||
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
|
||||
bootstrap_status=${PIPESTATUS[0]}
|
||||
set -e
|
||||
if [ "$bootstrap_status" -eq 0 ]; then
|
||||
echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
|
||||
echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2
|
||||
exit 1
|
||||
}
|
||||
rm -f "$bootstrap_log"
|
||||
|
||||
docker compose --env-file .env.example exec -T db \
|
||||
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
|
||||
DO $$
|
||||
DECLARE reset_rows integer;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO reset_rows
|
||||
FROM typeorm_migrations
|
||||
WHERE name = 'ResetProductionOperationalData1788652800000';
|
||||
IF reset_rows <> 0 THEN
|
||||
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
|
||||
END IF;
|
||||
INSERT INTO typeorm_migrations ("timestamp", name)
|
||||
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
docker compose --env-file .env.example --profile tools run --rm migrate
|
||||
|
||||
# The completed chain must end in the exact authoritative SQL model:
|
||||
# 7 Departamentos, 64 Áreas, 230 Yacimientos, 13 Empresas, 2 Tipos de
|
||||
# concesión, 14/109 technical families, 181 Hallazgos and their exact
|
||||
# 48 + 880 contextual mappings.
|
||||
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;
|
||||
legacy_territory_sources integer;
|
||||
legacy_presentation_sources integer;
|
||||
authoritative_sources integer;
|
||||
source_installations integer;
|
||||
source_subinstallations integer;
|
||||
source_findings integer;
|
||||
concession_types integer;
|
||||
departments integer;
|
||||
areas integer;
|
||||
yacimientos integer;
|
||||
companies integer;
|
||||
invalid_yacimientos integer;
|
||||
department_types integer;
|
||||
area_department_rules integer;
|
||||
yacimiento_area_rules integer;
|
||||
installation_yacimiento_rules integer;
|
||||
subinstallation_installation_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 <> 314 THEN
|
||||
RAISE EXCEPTION 'Authoritative model must contain 314 Assets, got %', domain_assets;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO audits FROM audit_events;
|
||||
IF audits <> 0 THEN
|
||||
RAISE EXCEPTION 'Clean authoritative rehearsal must start with 0 audit events, got %', audits;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families;
|
||||
IF applicability <> 928 THEN
|
||||
RAISE EXCEPTION 'Authoritative model must contain 928 finding applicability links, got %', applicability;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO legacy_territory_sources
|
||||
FROM source_documents
|
||||
WHERE document_number='DH-F5-TERRITORY';
|
||||
IF legacy_territory_sources <> 0 THEN
|
||||
RAISE EXCEPTION 'Authoritative model must remove the old F5 territory source, got % rows', legacy_territory_sources;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO legacy_presentation_sources
|
||||
FROM source_documents
|
||||
WHERE document_number='DH-F6.1-PRESENTATION-TERRITORY-20260909';
|
||||
IF legacy_presentation_sources <> 0 THEN
|
||||
RAISE EXCEPTION 'Authoritative model must remove the F6.1 presentation source, got % rows', legacy_presentation_sources;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO authoritative_sources
|
||||
FROM source_documents
|
||||
WHERE document_number IN ('DH-AUTH-TERRITORY-20260911','DH-AUTH-INVENTORY-20260911');
|
||||
IF authoritative_sources <> 2 THEN
|
||||
RAISE EXCEPTION 'Authoritative model must contain its two SQL source documents, got %', authoritative_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 'modelo_relacional:%';
|
||||
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
|
||||
RAISE EXCEPTION 'Authoritative technical families must be 14 installations and 109 subinstallations, got % and %', 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)='authmodel' AND item.is_active=true;
|
||||
IF source_findings <> 181 THEN
|
||||
RAISE EXCEPTION 'Authoritative finding catalog must contain 181 items, got %', source_findings;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO concession_types FROM concession_types WHERE is_active=true;
|
||||
IF concession_types <> 2 THEN
|
||||
RAISE EXCEPTION 'Authoritative model must contain 2 concession types, got %', concession_types;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) FILTER (WHERE lower(type.code)='departamento'),
|
||||
COUNT(*) FILTER (WHERE lower(type.code)='area'),
|
||||
COUNT(*) FILTER (WHERE lower(type.code)='yacimiento'),
|
||||
COUNT(*) FILTER (WHERE type.operational_role='COMPANY')
|
||||
INTO departments,areas,yacimientos,companies
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id;
|
||||
IF departments <> 7 OR areas <> 64 OR yacimientos <> 230 OR companies <> 13 THEN
|
||||
RAISE EXCEPTION 'Authoritative territory mismatch: departments %, areas %, yacimientos %, companies %', departments,areas,yacimientos,companies;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO invalid_yacimientos
|
||||
FROM assets yacimiento
|
||||
JOIN asset_types type ON type.id=yacimiento.asset_type_id
|
||||
JOIN assets area ON area.id=yacimiento.parent_id
|
||||
WHERE lower(type.code)='yacimiento'
|
||||
AND (
|
||||
yacimiento.operational_area_id IS DISTINCT FROM area.id
|
||||
OR yacimiento.operator_company_id IS NULL
|
||||
OR yacimiento.concession_type_id IS NULL
|
||||
);
|
||||
IF invalid_yacimientos <> 0 THEN
|
||||
RAISE EXCEPTION 'Authoritative model contains % invalid Yacimiento relationships', invalid_yacimientos;
|
||||
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;
|
||||
|
||||
SELECT COUNT(*) INTO yacimiento_area_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)='yacimiento' AND lower(parent.code)='area';
|
||||
IF yacimiento_area_rules <> 1 THEN
|
||||
RAISE EXCEPTION 'Expected Yacimiento → Área canonical rule, got %', yacimiento_area_rules;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO installation_yacimiento_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)='instalacion' AND lower(parent.code)='yacimiento';
|
||||
IF installation_yacimiento_rules <> 1 THEN
|
||||
RAISE EXCEPTION 'Expected Instalación → Yacimiento canonical rule, got %', installation_yacimiento_rules;
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*) INTO subinstallation_installation_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)='subinstalacion' AND lower(parent.code)='instalacion';
|
||||
IF subinstallation_installation_rules <> 1 THEN
|
||||
RAISE EXCEPTION 'Expected Subinstalación → Instalación canonical rule, got %', subinstallation_installation_rules;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL
|
||||
|
||||
# Destructive cuts are intentionally one-way: production rollback is
|
||||
# the PRE database backup, not migration:revert. Prove the completed
|
||||
# chain is idempotent and has no pending migration on a second run.
|
||||
rerun_log="$(mktemp)"
|
||||
docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log"
|
||||
grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || {
|
||||
echo "ERROR: authoritative migration chain is not idempotent." >&2
|
||||
cat "$rerun_log" >&2
|
||||
exit 1
|
||||
}
|
||||
rm -f "$rerun_log"
|
||||
|
||||
# A build-only preflight cannot catch Nest dependency-injection or
|
||||
# runtime configuration failures. Start the production API image against
|
||||
# the migrated database and require the public health endpoint to answer.
|
||||
export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||
export SMTP_SETTINGS_MASTER_KEY=''
|
||||
docker compose --env-file .env.example build api
|
||||
docker compose --env-file .env.example up -d api
|
||||
|
||||
api_ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then
|
||||
api_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$api_ready" -ne 1 ]; then
|
||||
echo 'ERROR: production API image did not become healthy.' >&2
|
||||
docker compose --env-file .env.example logs --no-color api >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq '"status":"ok"' /tmp/dhv2-health.json || {
|
||||
echo 'ERROR: /api/v3/health did not report status ok.' >&2
|
||||
cat /tmp/dhv2-health.json >&2
|
||||
exit 1
|
||||
}
|
||||
- name: Isolated builder test preflight
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Inspection planning smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'api-v3/src/inspection-visits/**'
|
||||
- 'api-v3/src/database/migrations/**'
|
||||
- 'api-v3/src/reference-data/**'
|
||||
- 'web-v2/src/pages/InspectionVisitCreateF61Page.tsx'
|
||||
- 'web-v2/src/pages/FieldBriefingsPage.tsx'
|
||||
- 'web-v2/src/layout/AppLayout.tsx'
|
||||
- 'scripts/ci-inspection-planning-smoke.sh'
|
||||
- '.github/workflows/inspection-planning-smoke.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
create-inspection:
|
||||
name: F6.1 · real inspection create
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Rehearse hierarchy and create a real Inspection
|
||||
run: bash scripts/ci-inspection-planning-smoke.sh
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Production dependency audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: dhv2-production-audit-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
api:
|
||||
name: API · production dependencies
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api-v3
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: api-v3/package-lock.json
|
||||
- name: Reject high/critical runtime advisories
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
web:
|
||||
name: WEB · production dependencies
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web-v2
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: web-v2/package-lock.json
|
||||
- name: Reject high/critical runtime advisories
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
@@ -2,9 +2,30 @@
|
||||
|
||||
Repositorio del sistema DH Inspección V2.
|
||||
|
||||
## Estructura
|
||||
## Componentes
|
||||
|
||||
- `api-v3/`: API v3.
|
||||
- `web-v2/`: aplicación web.
|
||||
- `scripts/`: utilidades operativas y de despliegue.
|
||||
- `docs/`: documentación técnica.
|
||||
- `api-v3/`: API NestJS/TypeORM y contratos de dominio.
|
||||
- `web-v2/`: aplicación WEB React/Vite.
|
||||
- `android-app/`: aplicación Android para trabajo de campo.
|
||||
- `scripts/`: utilidades operativas, preflight y despliegue.
|
||||
- `docs/`: documentación funcional y técnica.
|
||||
|
||||
## Documentación vigente
|
||||
|
||||
- [Corte de presentación F6.1](docs/PRESENTACION_F6_1.md)
|
||||
- [Manual del Programador](docs/MANUAL_PROGRAMADOR.md)
|
||||
- [Manual de Usuario](docs/MANUAL_USUARIO.md)
|
||||
- [Modelo canónico de Inventarios F6.1](docs/F6_INVENTORY_MODEL.md)
|
||||
- [Auditoría de consistencia F6.1](docs/AUDITORIA_CONSISTENCIA_F6_1.md)
|
||||
- [Contrato de release Android F6.1](android-app/RELEASE.md)
|
||||
|
||||
## Contratos que no deben romperse
|
||||
|
||||
- Inventario físico: **Departamento → Área → Yacimiento → Instalación → Subinstalación**.
|
||||
- Empresa es un maestro independiente; la Operadora se relaciona temporalmente con Área.
|
||||
- Una Inspección puede tener múltiples Actas, pero sólo un Acta `DRAFT` simultánea.
|
||||
- Hallazgos directos: Yacimiento, Instalación y Subinstalación, siempre con `OTROS` disponible.
|
||||
- La APK puede abrir una Inspección reutilizando el lifecycle canónico del backend.
|
||||
- GEDO/IF oficializa el Informe, pero no activa por sí solo el vencimiento No urgente.
|
||||
|
||||
Las reglas detalladas, el procedimiento de cambio seguro y el recorrido de demo están en los documentos enlazados arriba.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# F6.4 · Android Campo Moderno
|
||||
|
||||
Criterios de aceptación:
|
||||
|
||||
- el buscador de Instalación padre consulta mientras se escribe y cada resultado se puede seleccionar directamente;
|
||||
- la selección de Instalación abre la clasificación compatible de Subinstalación;
|
||||
- Actas utiliza un read-model móvil desacoplado del módulo documental de oficina;
|
||||
- la APK conserva GPS + foto antes de habilitar Hallazgos sobre Inventario nacido en campo;
|
||||
- el lifecycle Acta DRAFT → LOCKED → SEALED no cambia;
|
||||
- la interfaz de campo usa el tema DH moderno y mantiene acceso claro a Actas, Inventario y Hallazgos;
|
||||
- Android lint/tests/debug/release, API tests/build, WEB build y Docker contract deben quedar verdes.
|
||||
@@ -0,0 +1,66 @@
|
||||
# DH Inspección Android · release Campo Moderno 0.18.0
|
||||
|
||||
## Candidata vigente
|
||||
|
||||
- Fase funcional: **Campo Moderno / UX dinámica de Inventario y Actas**.
|
||||
- `versionName`: **0.18.0**.
|
||||
- `versionCode`: **27**.
|
||||
- Application ID release: `com.korexlabs.dhinspeccion`.
|
||||
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
|
||||
- API: `https://dhv2.korexlabs.com/api/v3/`.
|
||||
|
||||
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
|
||||
|
||||
## Novedades de campo 0.18.0
|
||||
|
||||
La APK adopta una capa visual Material 3 propia de DH: superficies más limpias, jerarquía visual más clara, botones y estados más legibles, tarjetas con menor ruido y una navegación consistente entre Inspección, Actas e Inventario.
|
||||
|
||||
La carga de una Subinstalación mantiene la integridad del modelo físico pero reduce pasos:
|
||||
|
||||
1. el buscador de **Instalaciones padre** ahora consulta mientras el inspector escribe;
|
||||
2. cada coincidencia aparece como tarjeta seleccionable y tocarla avanza directamente a la identificación de la Subinstalación;
|
||||
3. la clasificación técnica continúa filtrada por compatibilidad con la Instalación elegida;
|
||||
4. **Guardar y tomar foto** captura GPS y abre la cámara automáticamente.
|
||||
|
||||
Se conserva la regla de que un elemento nacido en campo no puede recibir Hallazgos hasta contar con GPS y fotografía.
|
||||
|
||||
## Actas en Android
|
||||
|
||||
El espacio de Actas fue rediseñado para mostrar con claridad:
|
||||
|
||||
- contexto de la Inspección y Yacimiento;
|
||||
- estado de cada Acta y cantidad de Hallazgos;
|
||||
- urgencia mediante selección explícita;
|
||||
- responsable de empresa, bloqueo, firmas, manifestación y sellado;
|
||||
- condición necesaria para cerrar la Inspección.
|
||||
|
||||
El listado que consume Android usa un read-model móvil liviano (`/inspection-visits/:visitId/acts/mobile`). Esta lectura no depende del módulo documental de oficina ni de `inspection_reports`, de modo que entrar a una Inspección en campo no queda acoplado a la proyección de Informes/GEDO.
|
||||
|
||||
## Barrera obligatoria
|
||||
|
||||
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar `Android CI / RC`:
|
||||
|
||||
1. validación de identidad, HTTPS y políticas básicas del manifest;
|
||||
2. Android lint;
|
||||
3. unit tests Android reales, con verificación de que exista al menos una prueba ejecutada;
|
||||
4. `assembleDebug`;
|
||||
5. `assembleRelease` para comprobar que la variante productiva compile;
|
||||
6. empaquetado del APK debug con SHA-256 y metadata de commit/versionado.
|
||||
|
||||
Además, `DH V2 CI` debe mantener verdes API, WEB y contrato Docker/migraciones antes de promover el cambio.
|
||||
|
||||
## Firma release
|
||||
|
||||
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero la APK productiva final debe firmarse con la clave histórica antes de instalarse como actualización de `com.korexlabs.dhinspeccion`.
|
||||
|
||||
No se debe crear una clave nueva para resolver una falta de acceso: eso rompería la continuidad de actualización de tablets que ya tengan una versión firmada con la clave anterior.
|
||||
|
||||
## Criterio de distribución
|
||||
|
||||
Antes de distribuir una APK productiva:
|
||||
|
||||
- todas las barreras de CI del SHA exacto deben estar verdes;
|
||||
- comprobar certificado/huella de firma contra la versión histórica;
|
||||
- realizar actualización sobre al menos una tablet con la versión productiva anterior;
|
||||
- ejecutar smoke funcional contra el entorno objetivo: login, abrir Inspección, abrir Actas sin error, crear Acta, búsqueda dinámica de Instalación, selección de padre, alta de Subinstalación, GPS/foto, Hallazgo y cierre;
|
||||
- registrar el SHA Git y SHA-256 de la APK distribuida.
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 19
|
||||
versionName = "0.12.0"
|
||||
versionCode = 27
|
||||
versionName = "0.18.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_mendoza_launcher_exact"
|
||||
android:roundIcon="@drawable/ic_mendoza_launcher_exact"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DHInspeccion"
|
||||
|
||||
@@ -49,6 +49,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
var inventory: List<FieldInventoryItem> by mutableStateOf(emptyList())
|
||||
private set
|
||||
var inventoryParentId: String? by mutableStateOf(null)
|
||||
private set
|
||||
var fieldTypes: List<FieldType> by mutableStateOf(emptyList())
|
||||
private set
|
||||
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
|
||||
@@ -96,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
visits = emptyList()
|
||||
visit = null
|
||||
inventory = emptyList()
|
||||
inventoryParentId = null
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearActState()
|
||||
@@ -112,6 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun openVisit(id: String) = launchBusy {
|
||||
visit = repository.visit(id)
|
||||
inventory = emptyList()
|
||||
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearFindingState()
|
||||
@@ -121,6 +125,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun closeVisitView() {
|
||||
visit = null
|
||||
inventory = emptyList()
|
||||
inventoryParentId = null
|
||||
fieldTypes = emptyList()
|
||||
selectedFieldAsset = null
|
||||
clearActState()
|
||||
@@ -132,6 +137,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val id = visit?.id ?: return
|
||||
launchBusy {
|
||||
visit = repository.startVisit(id)
|
||||
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||
notice = "Inspección iniciada."
|
||||
loadActsInternal(id, selectDraft = true)
|
||||
loadVisitsInternal()
|
||||
@@ -162,10 +168,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
error = "Elegí si el Acta es urgente o no urgente."
|
||||
return
|
||||
}
|
||||
if (asset == null) {
|
||||
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
|
||||
return
|
||||
}
|
||||
if (acts.any { it.status == "DRAFT" }) {
|
||||
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
|
||||
return
|
||||
@@ -173,7 +175,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
launchBusy {
|
||||
val created = actsRepository.create(
|
||||
currentVisit.id,
|
||||
asset.id,
|
||||
asset?.id,
|
||||
currentVisit.code,
|
||||
urgency,
|
||||
)
|
||||
@@ -182,32 +184,42 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
loadActsInternal(currentVisit.id, selectDraft = false)
|
||||
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
|
||||
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
if (asset != null && selectedFieldAsset?.capture?.readyForFinding == true) {
|
||||
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createAct(urgency: String = "NON_URGENT") = createActForSelectedInventory(urgency)
|
||||
|
||||
fun searchInventory(search: String, parentId: String? = null) {
|
||||
val id = visit?.id ?: return
|
||||
val currentVisit = visit ?: return
|
||||
launchBusy {
|
||||
inventory = repository.fieldInventory(id, search, parentId).data
|
||||
inventory = repository.fieldInventory(currentVisit.id, search, parentId).data
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFieldTypes(parentId: String? = null) {
|
||||
val id = visit?.id ?: return
|
||||
val currentVisit = visit ?: return
|
||||
launchBusy {
|
||||
fieldTypes = repository.fieldTypes(id, parentId).data
|
||||
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
|
||||
inventoryParentId = effectiveParentId
|
||||
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
|
||||
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadCurrentInventory(visitId: String) {
|
||||
val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id
|
||||
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
|
||||
}
|
||||
|
||||
fun selectExisting(item: FieldInventoryItem) {
|
||||
val visitId = visit?.id ?: return
|
||||
launchBusy {
|
||||
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
|
||||
notice = "Inventario agregado a la Inspección."
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
reloadCurrentInventory(visitId)
|
||||
val draft = selectedDraftAct()
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
|
||||
@@ -236,7 +248,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
if (type.familyRequired && familyId == null) {
|
||||
error = "Elegí una familia técnica o la opción Otro / no catalogado."
|
||||
error = "Elegí una clasificación técnica o la opción Otro / no catalogado."
|
||||
return
|
||||
}
|
||||
launchBusy {
|
||||
@@ -254,7 +266,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
selectedFieldAsset = repository.createFieldAsset(visitId, request)
|
||||
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
reloadCurrentInventory(visitId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +290,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
|
||||
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
reloadCurrentInventory(visitId)
|
||||
val draft = selectedDraftAct()
|
||||
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
|
||||
@@ -311,7 +323,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
} else {
|
||||
"Fotografía registrada."
|
||||
}
|
||||
inventory = repository.fieldInventory(visitId, null, null).data
|
||||
reloadCurrentInventory(visitId)
|
||||
val draft = selectedDraftAct()
|
||||
if (response.capture.readyForFinding && draft != null) {
|
||||
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
|
||||
|
||||
@@ -154,6 +154,7 @@ data class VisitDetail(
|
||||
val code: String,
|
||||
val objective: String? = null,
|
||||
val status: String,
|
||||
val scopeAsset: AssetSummary? = null,
|
||||
val operationalArea: AssetSummary? = null,
|
||||
val operatorCompany: AssetSummary? = null,
|
||||
val leadInspector: PersonSummary? = null,
|
||||
|
||||
@@ -207,7 +207,7 @@ data class MobileCloseVisitRequest(
|
||||
)
|
||||
|
||||
private interface MobileActsApi {
|
||||
@GET("inspection-visits/{visitId}/acts")
|
||||
@GET("inspection-visits/{visitId}/acts/mobile")
|
||||
suspend fun listActs(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("visitId") visitId: String,
|
||||
@@ -329,7 +329,7 @@ class MobileActsRepository(context: Context) {
|
||||
|
||||
suspend fun create(
|
||||
visitId: String,
|
||||
assetId: String,
|
||||
assetId: String? = null,
|
||||
visitCode: String,
|
||||
urgency: String = "NON_URGENT",
|
||||
): MobileActDetail = authorized { session ->
|
||||
@@ -341,7 +341,7 @@ class MobileActsRepository(context: Context) {
|
||||
urgency = urgency,
|
||||
title = "Acta de inspección $visitCode",
|
||||
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
|
||||
assetIds = listOf(assetId),
|
||||
assetIds = listOfNotNull(assetId),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.korexlabs.dhinspeccion.data
|
||||
|
||||
import android.content.Context
|
||||
import com.korexlabs.dhinspeccion.BuildConfig
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
data class MobilePlanningAsset(
|
||||
val id: String,
|
||||
val code: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
data class MobilePlanningAssetResponse(
|
||||
val data: List<MobilePlanningAsset> = emptyList(),
|
||||
)
|
||||
|
||||
data class OpenMobileInspectionRequest(
|
||||
val operationalAreaId: String,
|
||||
val operatorCompanyId: String,
|
||||
)
|
||||
|
||||
private interface MobileInspectionOpenApi {
|
||||
@GET("inspection-visits/mobile/planning-context/areas")
|
||||
suspend fun areas(
|
||||
@Header("Authorization") authorization: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators")
|
||||
suspend fun operators(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Path("areaId") areaId: String,
|
||||
): MobilePlanningAssetResponse
|
||||
|
||||
@POST("inspection-visits/mobile/open")
|
||||
suspend fun open(
|
||||
@Header("Authorization") authorization: String,
|
||||
@Body request: OpenMobileInspectionRequest,
|
||||
): VisitDetail
|
||||
|
||||
@POST("auth/mobile/refresh")
|
||||
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
|
||||
}
|
||||
|
||||
class MobileInspectionOpenRepository(context: Context) {
|
||||
private val store = SecureSessionStore(context.applicationContext)
|
||||
private val refreshMutex = Mutex()
|
||||
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
|
||||
private val api: MobileInspectionOpenApi = Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.client(OkHttpClient.Builder().build())
|
||||
.addConverterFactory(MoshiConverterFactory.create(moshi))
|
||||
.build()
|
||||
.create(MobileInspectionOpenApi::class.java)
|
||||
|
||||
suspend fun areas(): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.areas("Bearer ${session.accessToken}")
|
||||
}
|
||||
|
||||
suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session ->
|
||||
api.operators("Bearer ${session.accessToken}", areaId)
|
||||
}
|
||||
|
||||
suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session ->
|
||||
api.open(
|
||||
"Bearer ${session.accessToken}",
|
||||
OpenMobileInspectionRequest(areaId, companyId),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
|
||||
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||
try {
|
||||
return block(session)
|
||||
} catch (error: HttpException) {
|
||||
if (error.code() != 401) throw error
|
||||
}
|
||||
session = refresh(session.refreshToken)
|
||||
return block(session)
|
||||
}
|
||||
|
||||
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
|
||||
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
|
||||
if (latest.refreshToken != previousRefreshToken) return@withLock latest
|
||||
try {
|
||||
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
|
||||
} catch (error: Throwable) {
|
||||
store.clear()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,13 +547,17 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
try {
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val DhLightColors = lightColorScheme(
|
||||
primary = Color(0xFF075E63),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFD6F0F0),
|
||||
onPrimaryContainer = Color(0xFF073F43),
|
||||
secondary = Color(0xFF445E74),
|
||||
onSecondary = Color(0xFFFFFFFF),
|
||||
secondaryContainer = Color(0xFFDCE7F1),
|
||||
onSecondaryContainer = Color(0xFF243F54),
|
||||
tertiary = Color(0xFF6A5A42),
|
||||
background = Color(0xFFF6F8FA),
|
||||
onBackground = Color(0xFF182025),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF182025),
|
||||
surfaceVariant = Color(0xFFEDF1F4),
|
||||
onSurfaceVariant = Color(0xFF455158),
|
||||
outline = Color(0xFF77848B),
|
||||
outlineVariant = Color(0xFFD7DEE2),
|
||||
error = Color(0xFFB3261E),
|
||||
onError = Color(0xFFFFFFFF),
|
||||
errorContainer = Color(0xFFF9DEDC),
|
||||
onErrorContainer = Color(0xFF6F1713),
|
||||
)
|
||||
|
||||
private val DhDarkColors = darkColorScheme(
|
||||
primary = Color(0xFF88D4D7),
|
||||
onPrimary = Color(0xFF00373A),
|
||||
primaryContainer = Color(0xFF074F53),
|
||||
onPrimaryContainer = Color(0xFFB7EBED),
|
||||
secondary = Color(0xFFB6CBE0),
|
||||
onSecondary = Color(0xFF203646),
|
||||
secondaryContainer = Color(0xFF344C60),
|
||||
onSecondaryContainer = Color(0xFFDCE7F1),
|
||||
background = Color(0xFF101416),
|
||||
onBackground = Color(0xFFE0E4E6),
|
||||
surface = Color(0xFF171C1F),
|
||||
onSurface = Color(0xFFE0E4E6),
|
||||
surfaceVariant = Color(0xFF263035),
|
||||
onSurfaceVariant = Color(0xFFC3CDD2),
|
||||
outline = Color(0xFF8C989E),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
)
|
||||
|
||||
private val DhShapes = androidx.compose.material3.Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(12.dp),
|
||||
medium = RoundedCornerShape(16.dp),
|
||||
large = RoundedCornerShape(22.dp),
|
||||
extraLarge = RoundedCornerShape(28.dp),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DhTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(
|
||||
colorScheme = if (isSystemInDarkTheme()) DhDarkColors else DhLightColors,
|
||||
shapes = DhShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,972 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.tasks.CancellationTokenSource
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
|
||||
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
|
||||
import com.korexlabs.dhinspeccion.data.FieldType
|
||||
import com.korexlabs.dhinspeccion.data.VisitDetail
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private data class DynamicGeoSnapshot(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyM: Double?,
|
||||
)
|
||||
|
||||
private enum class DynamicInventoryMode {
|
||||
BROWSE,
|
||||
PICK_PARENT,
|
||||
CREATE_INSTALLATION,
|
||||
CREATE_SUBINSTALLATION,
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DynamicVisitRoot(model: MainViewModel) {
|
||||
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
|
||||
var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
|
||||
|
||||
when {
|
||||
actsMode -> MobileActsScreen(
|
||||
model = model,
|
||||
onBack = { actsMode = false },
|
||||
onGoInventory = {
|
||||
actsMode = false
|
||||
inventoryMode = true
|
||||
},
|
||||
)
|
||||
inventoryMode -> DynamicFieldInventoryScreen(
|
||||
model = model,
|
||||
onBack = {
|
||||
inventoryMode = false
|
||||
actsMode = true
|
||||
},
|
||||
)
|
||||
else -> DynamicVisitOverview(
|
||||
model = model,
|
||||
onInventory = { inventoryMode = true },
|
||||
onActs = { actsMode = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DynamicVisitOverview(
|
||||
model: MainViewModel,
|
||||
onInventory: () -> Unit,
|
||||
onActs: () -> Unit,
|
||||
) {
|
||||
val visit = model.visit ?: return
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 30.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = { model.closeVisitView() }) { Text("Volver") }
|
||||
Text(dynamicStatusLabel(visit.status), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
|
||||
visit.scopeAsset?.let {
|
||||
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
visit.plannedStartAt?.let {
|
||||
Text("Planificada: ${dynamicShortDate(it)}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
visit.instructions?.takeIf { it.isNotBlank() }?.let {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text("Instrucciones", fontWeight = FontWeight.Bold)
|
||||
Text(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
DynamicMessageStrip(model)
|
||||
|
||||
if (visit.status == "PLANNED") {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
|
||||
Text("Al iniciar se habilitan Actas, Hallazgos y altas rápidas de campo.")
|
||||
Button(
|
||||
onClick = { model.startVisit(); onActs() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visit.status == "IN_PROGRESS") {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Modo campo rápido", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Desde Inventario podés cargar una Subinstalación en tres pasos: elegir Instalación, identificarla y capturar GPS + foto.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("+ Nueva subinstalación / inventario")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
|
||||
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
|
||||
}
|
||||
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Buscar Inventario / Hallazgos")
|
||||
}
|
||||
} else if (visit.status == "CLOSED") {
|
||||
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Ver Actas · ${model.acts.size}")
|
||||
}
|
||||
}
|
||||
|
||||
DynamicChecklistCard(visit)
|
||||
|
||||
Text("Inventario planificado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (visit.planningAssets.none { it.included }) {
|
||||
Text("Sin elementos planificados.")
|
||||
}
|
||||
visit.planningAssets.filter { it.included }.forEach { asset ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(asset.name, fontWeight = FontWeight.SemiBold)
|
||||
Text("${asset.code} · ${asset.typeName.orEmpty()}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DynamicChecklistCard(visit: VisitDetail) {
|
||||
val checklist = visit.checklist
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Antecedentes antes de salir", fontWeight = FontWeight.Bold)
|
||||
Text("Vencidos empresa: ${checklist.companyOverdue} · Verificaciones vencidas: ${checklist.verificationOverdue}")
|
||||
Text("Antecedentes: ${checklist.antecedents} · Próximos controles: ${checklist.upcomingControls}")
|
||||
if (checklist.stale) {
|
||||
Text("El checklist requiere revisión/actualización.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DynamicFieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val visit = model.visit ?: return
|
||||
|
||||
var modeName by rememberSaveable(visit.id) { mutableStateOf(DynamicInventoryMode.BROWSE.name) }
|
||||
val mode = runCatching { DynamicInventoryMode.valueOf(modeName) }.getOrDefault(DynamicInventoryMode.BROWSE)
|
||||
var search by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var parentSearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var name by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var selectedFamilyId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var familySearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
val attributeValues = remember { mutableStateMapOf<String, String>() }
|
||||
var pendingAutoPhoto by rememberSaveable(visit.id) { mutableStateOf(false) }
|
||||
var localError by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var permissionAction by rememberSaveable(visit.id) { mutableStateOf("PHOTO") }
|
||||
|
||||
var mergeSearch by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var mergeCandidateId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var mergeReason by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
|
||||
fun resetForm() {
|
||||
name = ""
|
||||
commonName = ""
|
||||
selectedTypeId = null
|
||||
selectedFamilyId = null
|
||||
familySearch = ""
|
||||
attributeValues.clear()
|
||||
localError = null
|
||||
}
|
||||
|
||||
fun backToBrowse() {
|
||||
modeName = DynamicInventoryMode.BROWSE.name
|
||||
parentId = null
|
||||
parentLabel = ""
|
||||
parentSearch = ""
|
||||
resetForm()
|
||||
model.loadFieldTypes(null)
|
||||
}
|
||||
|
||||
fun startInstallation() {
|
||||
model.clearSelectedFieldAsset()
|
||||
modeName = DynamicInventoryMode.CREATE_INSTALLATION.name
|
||||
parentId = null
|
||||
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
|
||||
resetForm()
|
||||
model.loadFieldTypes(null)
|
||||
}
|
||||
|
||||
fun startSubinstallation() {
|
||||
model.clearSelectedFieldAsset()
|
||||
modeName = DynamicInventoryMode.PICK_PARENT.name
|
||||
parentId = null
|
||||
parentLabel = ""
|
||||
parentSearch = ""
|
||||
resetForm()
|
||||
model.searchInventory("", visit.scopeAsset?.id)
|
||||
}
|
||||
|
||||
fun chooseParent(item: FieldInventoryItem) {
|
||||
parentId = item.id
|
||||
parentLabel = "${item.name} (${item.code})"
|
||||
modeName = DynamicInventoryMode.CREATE_SUBINSTALLATION.name
|
||||
resetForm()
|
||||
model.loadFieldTypes(item.id)
|
||||
}
|
||||
|
||||
LaunchedEffect(visit.id) {
|
||||
model.searchInventory("", visit.scopeAsset?.id)
|
||||
}
|
||||
|
||||
LaunchedEffect(model.fieldTypes, modeName) {
|
||||
if (mode == DynamicInventoryMode.CREATE_INSTALLATION || mode == DynamicInventoryMode.CREATE_SUBINSTALLATION) {
|
||||
val preferred = model.fieldTypes.firstOrNull { type ->
|
||||
val normalized = dynamicNormalize(type.structuralKind ?: type.code.ifBlank { type.name })
|
||||
when (mode) {
|
||||
DynamicInventoryMode.CREATE_INSTALLATION -> normalized.contains("instalacion") && !normalized.contains("subinstalacion")
|
||||
DynamicInventoryMode.CREATE_SUBINSTALLATION -> normalized.contains("subinstalacion")
|
||||
else -> false
|
||||
}
|
||||
} ?: model.fieldTypes.firstOrNull()
|
||||
if (model.fieldTypes.none { it.id == selectedTypeId }) {
|
||||
selectedTypeId = preferred?.id
|
||||
selectedFamilyId = null
|
||||
familySearch = ""
|
||||
attributeValues.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val selectedType = model.fieldTypes.firstOrNull { it.id == selectedTypeId }
|
||||
val selectedFamily = selectedType?.families?.firstOrNull { it.id == selectedFamilyId }
|
||||
val filteredFamilies = remember(selectedType, familySearch) {
|
||||
val needle = dynamicNormalize(familySearch)
|
||||
selectedType?.families.orEmpty().filter { family ->
|
||||
needle.isBlank() || dynamicNormalize("${family.code} ${family.name}").contains(needle)
|
||||
}
|
||||
}
|
||||
|
||||
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
|
||||
var pendingPhotoGeo by remember { mutableStateOf<DynamicGeoSnapshot?>(null) }
|
||||
|
||||
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
val file = pendingPhotoFile
|
||||
val geo = pendingPhotoGeo
|
||||
if (success && file != null && geo != null) {
|
||||
runCatching { writeDynamicExif(file, geo) }
|
||||
model.uploadFieldPhoto(file, geo.latitude, geo.longitude, geo.accuracyM)
|
||||
} else if (!success) {
|
||||
localError = "La foto quedó pendiente. Podés tomarla cuando estés listo."
|
||||
}
|
||||
pendingPhotoFile = null
|
||||
pendingPhotoGeo = null
|
||||
}
|
||||
|
||||
val beginPhoto: () -> Unit = {
|
||||
scope.launch {
|
||||
runCatching { currentDynamicGeo(context) }
|
||||
.onSuccess { geo ->
|
||||
val (file, uri) = newDynamicPhoto(context)
|
||||
pendingPhotoFile = file
|
||||
pendingPhotoGeo = geo
|
||||
takePicture.launch(uri)
|
||||
}
|
||||
.onFailure { localError = it.message ?: "No se pudo obtener el GPS para la fotografía." }
|
||||
}
|
||||
}
|
||||
|
||||
fun createWithLocation() {
|
||||
val type = selectedType ?: return
|
||||
val effectiveParent = when (mode) {
|
||||
DynamicInventoryMode.CREATE_SUBINSTALLATION -> parentId
|
||||
else -> null
|
||||
}
|
||||
scope.launch {
|
||||
runCatching { currentDynamicGeo(context) }
|
||||
.onSuccess { geo ->
|
||||
pendingAutoPhoto = true
|
||||
model.createFieldAsset(
|
||||
type = type,
|
||||
parentId = effectiveParent,
|
||||
familyId = selectedFamilyId,
|
||||
name = name,
|
||||
commonName = commonName,
|
||||
attributes = buildDynamicAttributes(type, attributeValues),
|
||||
latitude = geo.latitude,
|
||||
longitude = geo.longitude,
|
||||
accuracyM = geo.accuracyM,
|
||||
)
|
||||
}
|
||||
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
|
||||
}
|
||||
}
|
||||
|
||||
val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
val cameraAllowed = result[Manifest.permission.CAMERA] == true || dynamicHasPermission(context, Manifest.permission.CAMERA)
|
||||
val locationAllowed = result[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
|
||||
result[Manifest.permission.ACCESS_COARSE_LOCATION] == true || dynamicHasLocation(context)
|
||||
when (permissionAction) {
|
||||
"CREATE" -> {
|
||||
if (cameraAllowed && locationAllowed) createWithLocation()
|
||||
else localError = "Para el alta rápida se necesitan cámara y ubicación."
|
||||
}
|
||||
else -> {
|
||||
if (cameraAllowed && locationAllowed) beginPhoto()
|
||||
else localError = "Para completar el alta se necesitan cámara y ubicación."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestCreate() {
|
||||
permissionAction = "CREATE"
|
||||
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
|
||||
createWithLocation()
|
||||
} else {
|
||||
permissionsLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestPhoto() {
|
||||
permissionAction = "PHOTO"
|
||||
if (dynamicHasPermission(context, Manifest.permission.CAMERA) && dynamicHasLocation(context)) {
|
||||
beginPhoto()
|
||||
} else {
|
||||
permissionsLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.CAMERA,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val selectedCapture = model.selectedFieldAsset
|
||||
LaunchedEffect(selectedCapture?.asset?.id, pendingAutoPhoto) {
|
||||
val capture = selectedCapture?.capture
|
||||
if (pendingAutoPhoto && capture?.captureRequired == true && capture.creationGpsCaptured && capture.fieldPhotoCount == 0) {
|
||||
pendingAutoPhoto = false
|
||||
requestPhoto()
|
||||
}
|
||||
}
|
||||
|
||||
val mergeSource = selectedCapture?.takeIf {
|
||||
it.capture.captureRequired && dynamicItemTypeCode(it.asset) in setOf("instalacion", "subinstalacion")
|
||||
}
|
||||
val mergeCandidates = remember(model.inventory, mergeSource) {
|
||||
if (mergeSource == null) emptyList() else model.inventory.filter { candidate ->
|
||||
candidate.id != mergeSource.asset.id &&
|
||||
candidate.informationStatus != "INACTIVE" &&
|
||||
candidate.type?.id == mergeSource.asset.type?.id &&
|
||||
candidate.parent?.id == mergeSource.asset.parent?.id &&
|
||||
!candidate.captureRequired
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = { if (mode == DynamicInventoryMode.BROWSE) onBack() else backToBrowse() }) {
|
||||
Text(if (mode == DynamicInventoryMode.BROWSE) "Acta" else "Atrás")
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
when (mode) {
|
||||
DynamicInventoryMode.PICK_PARENT -> "Elegir Instalación"
|
||||
DynamicInventoryMode.CREATE_INSTALLATION -> "Nueva Instalación"
|
||||
DynamicInventoryMode.CREATE_SUBINSTALLATION -> "Nueva Subinstalación"
|
||||
else -> "Inventario de campo"
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(visit.scopeAsset?.name ?: visit.code, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.padding(horizontal = 16.dp)) {
|
||||
DynamicMessageStrip(model)
|
||||
localError?.let {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
onClick = { localError = null },
|
||||
) { Text(it, Modifier.padding(12.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCapture != null) {
|
||||
DynamicCaptureCard(
|
||||
item = selectedCapture.asset,
|
||||
gps = selectedCapture.capture.creationGpsCaptured,
|
||||
photos = selectedCapture.capture.fieldPhotoCount,
|
||||
ready = selectedCapture.capture.readyForFinding,
|
||||
onPhoto = { requestPhoto() },
|
||||
onClose = { model.clearSelectedFieldAsset() },
|
||||
onCreateAnother = {
|
||||
if (dynamicItemTypeCode(selectedCapture.asset) == "subinstalacion" && selectedCapture.asset.parent != null) {
|
||||
chooseParent(
|
||||
FieldInventoryItem(
|
||||
id = selectedCapture.asset.parent.id,
|
||||
code = selectedCapture.asset.parent.code,
|
||||
name = selectedCapture.asset.parent.name,
|
||||
type = null,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
startSubinstallation()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (mergeSource != null && !mergeSource.capture.readyForFinding) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("¿Ya existía?", fontWeight = FontWeight.Bold)
|
||||
Text("Antes de seguir, podés fusionar el alta con un registro existente compatible.", style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
value = mergeSearch,
|
||||
onValueChange = { mergeSearch = it },
|
||||
label = { Text("Buscar posible duplicado") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
mergeCandidateId = null
|
||||
model.searchInventory(mergeSearch, mergeSource.asset.parent?.id)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Buscar coincidencias") }
|
||||
mergeCandidates.take(6).forEach { candidate ->
|
||||
AssistChip(
|
||||
onClick = { mergeCandidateId = candidate.id },
|
||||
label = {
|
||||
Text(if (mergeCandidateId == candidate.id) "✓ ${candidate.code} · ${candidate.name}" else "${candidate.code} · ${candidate.name}")
|
||||
},
|
||||
)
|
||||
}
|
||||
if (mergeCandidateId != null) {
|
||||
OutlinedTextField(
|
||||
value = mergeReason,
|
||||
onValueChange = { mergeReason = it },
|
||||
label = { Text("Motivo de la fusión") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 2,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.mergeCreatedFieldAsset(mergeCandidateId!!, mergeReason) },
|
||||
enabled = mergeReason.trim().length >= 8 && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Fusionar con el existente") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (mode) {
|
||||
DynamicInventoryMode.BROWSE -> {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Alta rápida en campo", fontWeight = FontWeight.Bold)
|
||||
Text("La Subinstalación se carga guiada, sin navegar por formularios largos.", style = MaterialTheme.typography.bodySmall)
|
||||
Button(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
|
||||
Text("+ Nueva Subinstalación")
|
||||
}
|
||||
OutlinedButton(onClick = { startInstallation() }, modifier = Modifier.fillMaxWidth(), enabled = visit.status == "IN_PROGRESS") {
|
||||
Text("+ Nueva Instalación")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Buscar instalación, subinstalación o código") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.searchInventory(search) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Buscar") }
|
||||
}
|
||||
|
||||
Text("Inventario disponible", Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(
|
||||
model.inventory.filter { dynamicItemTypeCode(it) in setOf("instalacion", "subinstalacion") },
|
||||
key = { it.id },
|
||||
) { item ->
|
||||
DynamicInventoryCard(
|
||||
item = item,
|
||||
onInspect = { model.selectExisting(item) },
|
||||
onAddChild = { chooseParent(item) },
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(30.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
DynamicInventoryMode.PICK_PARENT -> {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
DynamicStepHeader(1, "Elegí la Instalación padre")
|
||||
Text("Solo se muestran Instalaciones del Yacimiento de esta inspección.", style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
value = parentSearch,
|
||||
onValueChange = { parentSearch = it },
|
||||
label = { Text("Buscar Instalación por nombre o código") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.searchInventory(parentSearch, visit.scopeAsset?.id) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Buscar Instalación") }
|
||||
}
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(
|
||||
model.inventory.filter { dynamicItemTypeCode(it) == "instalacion" },
|
||||
key = { it.id },
|
||||
) { item ->
|
||||
Card(onClick = { chooseParent(item) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(item.name, fontWeight = FontWeight.Bold)
|
||||
Text(item.code, style = MaterialTheme.typography.bodySmall)
|
||||
Text("Tocar para agregar una Subinstalación", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
DynamicInventoryMode.CREATE_INSTALLATION,
|
||||
DynamicInventoryMode.CREATE_SUBINSTALLATION -> {
|
||||
val isSubinstallation = mode == DynamicInventoryMode.CREATE_SUBINSTALLATION
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
DynamicStepHeader(if (isSubinstallation) 2 else 1, if (isSubinstallation) "Identificá la Subinstalación" else "Identificá la Instalación")
|
||||
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold)
|
||||
if (isSubinstallation) {
|
||||
OutlinedButton(onClick = { startSubinstallation() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Cambiar Instalación padre")
|
||||
}
|
||||
}
|
||||
|
||||
if (model.fieldTypes.isEmpty()) {
|
||||
Text("No hay un tipo habilitado para esta ubicación.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
if (model.fieldTypes.size > 1) {
|
||||
Text("Nivel de Inventario", fontWeight = FontWeight.Bold)
|
||||
model.fieldTypes.forEach { type ->
|
||||
AssistChip(
|
||||
onClick = {
|
||||
selectedTypeId = type.id
|
||||
selectedFamilyId = null
|
||||
attributeValues.clear()
|
||||
},
|
||||
label = { Text(if (selectedTypeId == type.id) "✓ ${type.name}" else type.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedType?.familyRequired == true) {
|
||||
Text("Clasificación técnica *", fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(
|
||||
value = familySearch,
|
||||
onValueChange = { familySearch = it },
|
||||
label = { Text("Buscar clasificación") },
|
||||
supportingText = { Text("${filteredFamilies.size} opciones compatibles") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
filteredFamilies.take(12).forEach { family ->
|
||||
AssistChip(
|
||||
onClick = { selectedFamilyId = family.id },
|
||||
label = {
|
||||
val prefix = when {
|
||||
selectedFamilyId == family.id -> "✓ "
|
||||
family.isOther -> "+ "
|
||||
else -> ""
|
||||
}
|
||||
Text(prefix + family.name)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (filteredFamilies.size > 12) {
|
||||
Text("Escribí parte del nombre para reducir la lista.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
selectedFamily?.let { family ->
|
||||
if (family.isOther) {
|
||||
Text("Quedará marcado como no catalogado para revisión en oficina.", color = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
if (family.informationLabels.isNotEmpty()) {
|
||||
Text("Datos esperados: ${family.informationLabels.joinToString(" · ")}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Nombre o código visible *") },
|
||||
supportingText = { Text("Usá lo que ve el inspector en la placa o en campo.") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = commonName,
|
||||
onValueChange = { commonName = it },
|
||||
label = { Text("Nombre habitual") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
|
||||
selectedType?.attributes?.forEach { definition ->
|
||||
OutlinedTextField(
|
||||
value = attributeValues[definition.code].orEmpty(),
|
||||
onValueChange = { attributeValues[definition.code] = it },
|
||||
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
|
||||
supportingText = {
|
||||
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
|
||||
if (details.isNotBlank()) Text(details)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")) KeyboardType.Decimal else KeyboardType.Text,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
DynamicStepHeader(if (isSubinstallation) 3 else 2, "GPS + foto en un solo paso")
|
||||
Text(
|
||||
"Al guardar se captura la ubicación actual y la cámara se abre automáticamente. La foto sigue siendo obligatoria antes de crear Hallazgos.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
val attributesReady = selectedType?.attributes
|
||||
?.filter { it.isRequired }
|
||||
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
|
||||
?: false
|
||||
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
||||
Button(
|
||||
onClick = { requestCreate() },
|
||||
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (model.busy) "Guardando…" else "Guardar y tomar foto") }
|
||||
Spacer(Modifier.height(30.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DynamicStepHeader(step: Int, title: String) {
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
|
||||
Text(step.toString(), Modifier.padding(horizontal = 10.dp, vertical = 6.dp), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DynamicCaptureCard(
|
||||
item: FieldInventoryItem,
|
||||
gps: Boolean,
|
||||
photos: Int,
|
||||
ready: Boolean,
|
||||
onPhoto: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onCreateAnother: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (ready) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(item.name, fontWeight = FontWeight.Bold)
|
||||
Text(item.code, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
OutlinedButton(onClick = onClose) { Text("Cerrar") }
|
||||
}
|
||||
Text("GPS: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
|
||||
if (!ready) {
|
||||
Text("Falta la fotografía obligatoria.", color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto ahora") }
|
||||
} else {
|
||||
Text("Alta completa · lista para Hallazgos", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||
OutlinedButton(onClick = onCreateAnother, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("+ Crear otra Subinstalación")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DynamicInventoryCard(
|
||||
item: FieldInventoryItem,
|
||||
onInspect: () -> Unit,
|
||||
onAddChild: () -> Unit,
|
||||
) {
|
||||
val typeCode = dynamicItemTypeCode(item)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(item.name, fontWeight = FontWeight.SemiBold)
|
||||
Text(if (typeCode == "subinstalacion") "Subinstalación" else "Instalación", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(item.code, style = MaterialTheme.typography.bodySmall)
|
||||
item.commonName?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
Text(
|
||||
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
|
||||
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (item.selectedInInspection) "Abrir para Hallazgo" else "Usar para Hallazgo")
|
||||
}
|
||||
if (typeCode == "instalacion") {
|
||||
OutlinedButton(onClick = onAddChild, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("+ Agregar Subinstalación")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DynamicMessageStrip(model: MainViewModel) {
|
||||
val error = model.error
|
||||
val notice = model.notice
|
||||
if (error != null || notice != null) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (error != null) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||
),
|
||||
onClick = { model.clearMessages() },
|
||||
) {
|
||||
Text(error ?: notice.orEmpty(), Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dynamicItemTypeCode(item: FieldInventoryItem): String {
|
||||
val type = item.type ?: return ""
|
||||
return dynamicNormalize(type.typeName ?: type.name)
|
||||
}
|
||||
|
||||
private fun dynamicNormalize(value: String): String = value.trim().lowercase()
|
||||
.replace('ó', 'o')
|
||||
.replace('í', 'i')
|
||||
.replace('á', 'a')
|
||||
.replace('é', 'e')
|
||||
.replace('ú', 'u')
|
||||
.replace("_", "")
|
||||
.replace("-", "")
|
||||
.replace(" ", "")
|
||||
|
||||
private fun dynamicStatusLabel(status: String): String = when (status) {
|
||||
"PLANNED" -> "Planificada"
|
||||
"IN_PROGRESS" -> "En curso"
|
||||
"CLOSED" -> "Cerrada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
}
|
||||
|
||||
private fun buildDynamicAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
|
||||
type.attributes.mapNotNull { definition ->
|
||||
val raw = values[definition.code]?.trim().orEmpty()
|
||||
if (raw.isBlank()) return@mapNotNull null
|
||||
definition.code to coerceDynamicAttribute(definition, raw)
|
||||
}.toMap()
|
||||
|
||||
private fun coerceDynamicAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
|
||||
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
|
||||
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
|
||||
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "sí", "yes")
|
||||
else -> raw
|
||||
}
|
||||
|
||||
private fun dynamicHasPermission(context: Context, permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun dynamicHasLocation(context: Context): Boolean =
|
||||
dynamicHasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ||
|
||||
dynamicHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
|
||||
private suspend fun currentDynamicGeo(context: Context): DynamicGeoSnapshot = suspendCancellableCoroutine { continuation ->
|
||||
if (!dynamicHasLocation(context)) {
|
||||
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||
try {
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) {
|
||||
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
} else {
|
||||
continuation.resume(DynamicGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
private fun newDynamicPhoto(context: Context): Pair<File, Uri> {
|
||||
val directory = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
||||
?: throw IllegalStateException("No se pudo acceder al almacenamiento de fotografías.")
|
||||
directory.mkdirs()
|
||||
val file = File.createTempFile("DH_FAST_${System.currentTimeMillis()}_", ".jpg", directory)
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
|
||||
return file to uri
|
||||
}
|
||||
|
||||
private fun writeDynamicExif(file: File, geo: DynamicGeoSnapshot) {
|
||||
val now = Instant.now()
|
||||
val exif = ExifInterface(file)
|
||||
exif.setLatLong(geo.latitude, geo.longitude)
|
||||
val formatter = DateTimeFormatter.ofPattern("yyyy:MM:dd HH:mm:ss").withZone(ZoneId.systemDefault())
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, formatter.format(now))
|
||||
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, formatter.format(now))
|
||||
exif.saveAttributes()
|
||||
}
|
||||
|
||||
private fun dynamicShortDate(value: String): String = value.replace('T', ' ').take(16)
|
||||
@@ -86,7 +86,10 @@ fun F3VisitRoot(model: MainViewModel) {
|
||||
inventoryMode = true
|
||||
},
|
||||
)
|
||||
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
|
||||
inventoryMode -> F3FieldInventoryScreen(model, onBack = {
|
||||
inventoryMode = false
|
||||
actsMode = true
|
||||
})
|
||||
else -> F3VisitOverview(
|
||||
model = model,
|
||||
onInventory = { inventoryMode = true },
|
||||
@@ -119,6 +122,9 @@ private fun F3VisitOverview(
|
||||
}
|
||||
Text(visit.code, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
Text("${visit.operatorCompany?.name ?: "Sin operadora"} · ${visit.operationalArea?.name ?: "Sin área"}")
|
||||
visit.scopeAsset?.let {
|
||||
Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
visit.plannedStartAt?.let {
|
||||
Text("Planificada: ${f3ShortDate(it)}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
@@ -138,10 +144,10 @@ private fun F3VisitOverview(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold)
|
||||
Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.")
|
||||
Text("Todo listo para comenzar", fontWeight = FontWeight.Bold)
|
||||
Text("Al iniciar, pasás directamente a las Actas de esta inspección.")
|
||||
Button(
|
||||
onClick = { model.startVisit() },
|
||||
onClick = { model.startVisit(); onActs() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
|
||||
@@ -150,12 +156,12 @@ private fun F3VisitOverview(
|
||||
}
|
||||
|
||||
if (visit.status == "IN_PROGRESS") {
|
||||
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Abrir Inventario de campo")
|
||||
}
|
||||
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
Button(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
|
||||
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
|
||||
Text("Abrir Actas · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
|
||||
}
|
||||
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Inventario / Hallazgos")
|
||||
}
|
||||
} else if (visit.status == "CLOSED") {
|
||||
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -163,7 +169,7 @@ private fun F3VisitOverview(
|
||||
}
|
||||
} else if (visit.status == "PLANNED") {
|
||||
Text(
|
||||
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
|
||||
"Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
@@ -209,7 +215,9 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
|
||||
var search by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var showCreate by rememberSaveable(visit.id) { mutableStateOf(false) }
|
||||
var parentId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") }
|
||||
var parentLabel by rememberSaveable(visit.id) {
|
||||
mutableStateOf(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
|
||||
}
|
||||
var name by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
|
||||
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
|
||||
@@ -323,10 +331,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onBack) { Text("Volver") }
|
||||
OutlinedButton(onClick = onBack) { Text("Acta") }
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Área → Yacimiento → Instalación → Subinstalación", style = MaterialTheme.typography.bodySmall)
|
||||
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
|
||||
@@ -410,170 +418,182 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Buscar por nombre o código") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text("Alta en campo", fontWeight = FontWeight.Bold)
|
||||
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("¿Dónde encontraste el Hallazgo?", fontWeight = FontWeight.Bold)
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Buscar instalación o subinstalación") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { model.searchInventory(search) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Buscar") }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
showCreate = !showCreate
|
||||
if (showCreate) model.loadFieldTypes(parentId)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text(if (showCreate) "Cancelar" else "+ Agregar") }
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = {
|
||||
showCreate = !showCreate
|
||||
if (showCreate) model.loadFieldTypes(parentId)
|
||||
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
|
||||
}
|
||||
|
||||
if (showCreate) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.weight(1f, fill = false),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (parentId != null) {
|
||||
OutlinedButton(onClick = {
|
||||
parentId = null
|
||||
parentLabel = "Área de la inspección"
|
||||
resetCreateForm()
|
||||
selectedTypeId = null
|
||||
model.loadFieldTypes(null)
|
||||
}) { Text("Volver al Área") }
|
||||
}
|
||||
Card(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.weight(1f, fill = false),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Nueva alta de campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Ubicación: $parentLabel", style = MaterialTheme.typography.bodySmall)
|
||||
if (parentId != null) {
|
||||
OutlinedButton(onClick = {
|
||||
parentId = null
|
||||
parentLabel = visit.scopeAsset?.name ?: "Yacimiento de la inspección"
|
||||
resetCreateForm()
|
||||
selectedTypeId = null
|
||||
model.loadFieldTypes(null)
|
||||
}) { Text("Volver al Yacimiento") }
|
||||
}
|
||||
|
||||
if (model.fieldTypes.isEmpty()) {
|
||||
Text("Este nivel no admite más hijos estructurales.")
|
||||
} else {
|
||||
Text("Vas a crear", style = MaterialTheme.typography.bodySmall)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(model.fieldTypes, key = { it.id }) { type ->
|
||||
AssistChip(
|
||||
onClick = {
|
||||
selectedTypeId = type.id
|
||||
selectedFamilyId = null
|
||||
attributeValues.clear()
|
||||
},
|
||||
label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) },
|
||||
)
|
||||
if (model.fieldTypes.isEmpty()) {
|
||||
Text("No hay un tipo disponible para esta ubicación.")
|
||||
} else {
|
||||
Text("Tipo de registro", style = MaterialTheme.typography.bodySmall)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(model.fieldTypes, key = { it.id }) { type ->
|
||||
AssistChip(
|
||||
onClick = {
|
||||
selectedTypeId = type.id
|
||||
selectedFamilyId = null
|
||||
attributeValues.clear()
|
||||
},
|
||||
label = { Text(if (type.id == selectedTypeId) "✓ ${type.name}" else type.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedType?.familyRequired == true) {
|
||||
Text("Familia técnica", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(selectedType.families, key = { it.id }) { family ->
|
||||
AssistChip(
|
||||
onClick = { selectedFamilyId = family.id },
|
||||
label = {
|
||||
val prefix = when {
|
||||
selectedFamilyId == family.id -> "✓ "
|
||||
family.isOther -> "+ "
|
||||
else -> ""
|
||||
}
|
||||
Text(prefix + family.name)
|
||||
},
|
||||
)
|
||||
if (selectedType?.familyRequired == true) {
|
||||
Text("Clasificación", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí el tipo técnico. Si no está catalogado, usá Otro / no catalogado.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(selectedType.families, key = { it.id }) { family ->
|
||||
AssistChip(
|
||||
onClick = { selectedFamilyId = family.id },
|
||||
label = {
|
||||
val prefix = when {
|
||||
selectedFamilyId == family.id -> "✓ "
|
||||
family.isOther -> "+ "
|
||||
else -> ""
|
||||
}
|
||||
Text(prefix + family.name)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
selectedFamily?.let { family ->
|
||||
if (family.isOther) {
|
||||
Text(
|
||||
"Se registrará como no catalogado para revisión posterior en oficina.",
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (family.informationLabels.isNotEmpty()) {
|
||||
Text(
|
||||
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedFamily?.let { family ->
|
||||
if (family.isOther) {
|
||||
Text(
|
||||
"Se registrará como familia no catalogada para revisión posterior en oficina.",
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (family.informationLabels.isNotEmpty()) {
|
||||
Text(
|
||||
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Nombre identificable *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = commonName,
|
||||
onValueChange = { commonName = it },
|
||||
label = { Text("Nombre habitual") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
selectedType?.attributes?.forEach { definition ->
|
||||
OutlinedTextField(
|
||||
value = attributeValues[definition.code].orEmpty(),
|
||||
onValueChange = { attributeValues[definition.code] = it },
|
||||
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
|
||||
supportingText = {
|
||||
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
|
||||
if (details.isNotBlank()) Text(details)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (
|
||||
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
|
||||
) KeyboardType.Decimal else KeyboardType.Text,
|
||||
),
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Nombre o código identificable *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = commonName,
|
||||
onValueChange = { commonName = it },
|
||||
label = { Text("Nombre habitual") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
val attributesReady = selectedType?.attributes
|
||||
?.filter { it.isRequired }
|
||||
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
|
||||
?: false
|
||||
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
||||
Button(
|
||||
onClick = {
|
||||
if (f3HasLocation(context)) createWithLocation()
|
||||
else locationPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
selectedType?.attributes?.forEach { definition ->
|
||||
OutlinedTextField(
|
||||
value = attributeValues[definition.code].orEmpty(),
|
||||
onValueChange = { attributeValues[definition.code] = it },
|
||||
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
|
||||
supportingText = {
|
||||
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
|
||||
if (details.isNotBlank()) Text(details)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (
|
||||
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
|
||||
) KeyboardType.Decimal else KeyboardType.Text,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Capturar GPS y crear") }
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
val attributesReady = selectedType?.attributes
|
||||
?.filter { it.isRequired }
|
||||
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
|
||||
?: false
|
||||
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
|
||||
Button(
|
||||
onClick = {
|
||||
if (f3HasLocation(context)) createWithLocation()
|
||||
else locationPermissionLauncher.launch(
|
||||
arrayOf(
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Guardar alta y capturar GPS") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Instalaciones y subinstalaciones",
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(model.inventory, key = { it.id }) { item ->
|
||||
items(
|
||||
model.inventory.filter { candidate ->
|
||||
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
|
||||
},
|
||||
key = { it.id },
|
||||
) { item ->
|
||||
F3InventoryCard(
|
||||
item = item,
|
||||
onInspect = { model.selectExisting(item) },
|
||||
@@ -611,10 +631,10 @@ private fun F3CaptureCard(
|
||||
if (captureRequired) {
|
||||
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
|
||||
if (!ready) {
|
||||
Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
|
||||
Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
|
||||
} else {
|
||||
Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
|
||||
Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
} else {
|
||||
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
|
||||
@@ -631,8 +651,8 @@ private fun F3InventoryCard(
|
||||
) {
|
||||
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
|
||||
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
|
||||
val canHaveChild = typeCode in setOf("yacimiento", "instalacion")
|
||||
val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí"
|
||||
val canHaveChild = typeCode == "instalacion"
|
||||
val childLabel = "+ Agregar subinstalación"
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
@@ -646,12 +666,12 @@ private fun F3InventoryCard(
|
||||
}
|
||||
if (canHaveFinding) {
|
||||
Text(
|
||||
if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente",
|
||||
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
|
||||
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
|
||||
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
|
||||
}
|
||||
}
|
||||
if (canHaveChild) {
|
||||
@@ -727,16 +747,20 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) {
|
||||
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
} else {
|
||||
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
try {
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) {
|
||||
continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
} else {
|
||||
continuation.resume(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
|
||||
@@ -389,13 +389,17 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
val client = LocationServices.getFusedLocationProviderClient(context)
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
try {
|
||||
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
|
||||
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
|
||||
if (model.session == null) unlocked = false
|
||||
}
|
||||
|
||||
MaterialTheme {
|
||||
DhTheme {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
when {
|
||||
model.session == null -> EnhancedLoginScreen(model) {
|
||||
@@ -91,8 +91,8 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
|
||||
},
|
||||
)
|
||||
model.fieldFindingOptions != null -> FieldFindingScreen(model)
|
||||
model.visit != null -> F3VisitRoot(model)
|
||||
else -> DhApp(model)
|
||||
model.visit != null -> ModernVisitRoot(model)
|
||||
else -> MobileHomeScreen(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,13 +126,14 @@ fun MobileActsScreen(
|
||||
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
|
||||
visit.scopeAsset?.let { Text("Yacimiento: ${it.name}", style = MaterialTheme.typography.bodySmall) }
|
||||
F32ActMessage(model)
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
|
||||
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.")
|
||||
Text("Todavía no hay Actas. Creá la primera y después agregá los Hallazgos.")
|
||||
}
|
||||
model.acts.forEach { act ->
|
||||
val active = selected?.id == act.id
|
||||
@@ -156,15 +157,15 @@ fun MobileActsScreen(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Nueva Acta", fontWeight = FontWeight.Bold)
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (model.acts.any { it.status == "LOCKED" }) {
|
||||
Text(
|
||||
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
|
||||
Text("Urgencia", fontWeight = FontWeight.Bold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(
|
||||
onClick = { newActUrgency = "NON_URGENT" },
|
||||
@@ -183,20 +184,15 @@ fun MobileActsScreen(
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
val selectedInventory = model.selectedFieldAsset?.asset
|
||||
if (selectedInventory == null) {
|
||||
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Ir a Inventario y elegir")
|
||||
}
|
||||
} else {
|
||||
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
|
||||
Button(
|
||||
onClick = { model.createActForSelectedInventory(newActUrgency) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Crear nueva Acta") }
|
||||
}
|
||||
Text(
|
||||
"Abrí el Acta primero. Después elegís la Instalación o Subinstalación al agregar cada Hallazgo.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.createAct(newActUrgency) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Crear nueva Acta") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,7 +216,21 @@ fun MobileActsScreen(
|
||||
|
||||
when (selected.status) {
|
||||
"DRAFT" -> {
|
||||
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Buscá una Instalación o Subinstalación existente. Si no está, podés agregarla en campo en el mismo flujo.")
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("+ Agregar Hallazgo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
|
||||
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
|
||||
@@ -259,12 +269,7 @@ fun MobileActsScreen(
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
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. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
@@ -431,13 +436,17 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
|
||||
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
try {
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
|
||||
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import com.korexlabs.dhinspeccion.data.DhRepository
|
||||
import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository
|
||||
import com.korexlabs.dhinspeccion.data.MobilePlanningAsset
|
||||
import com.korexlabs.dhinspeccion.data.VisitSummary
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun MobileHomeScreen(model: MainViewModel) {
|
||||
val session = model.session ?: return
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val openRepository = remember(context) { MobileInspectionOpenRepository(context) }
|
||||
|
||||
var showOpen by rememberSaveable { mutableStateOf(false) }
|
||||
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
|
||||
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var loadingContext by remember { mutableStateOf(false) }
|
||||
var opening by remember { mutableStateOf(false) }
|
||||
var localError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun loadAreas() {
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.areas().data }
|
||||
.onSuccess { areas = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseArea(areaId: String) {
|
||||
selectedAreaId = areaId
|
||||
selectedCompanyId = null
|
||||
operators = emptyList()
|
||||
scope.launch {
|
||||
loadingContext = true
|
||||
localError = null
|
||||
runCatching { openRepository.operators(areaId).data }
|
||||
.onSuccess { operators = it }
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
loadingContext = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showOpen) {
|
||||
if (showOpen && areas.isEmpty()) loadAreas()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = 28.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Row {
|
||||
OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) {
|
||||
Text("Actualizar")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
showOpen = !showOpen
|
||||
localError = null
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !opening,
|
||||
) {
|
||||
Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección")
|
||||
}
|
||||
|
||||
if (showOpen) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
Text("1. Área", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && areas.isEmpty()) CircularProgressIndicator()
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(areas, key = { it.id }) { area ->
|
||||
AssistChip(
|
||||
onClick = { chooseArea(area.id) },
|
||||
label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAreaId != null) {
|
||||
Text("2. Operadora", fontWeight = FontWeight.SemiBold)
|
||||
if (loadingContext && operators.isEmpty()) {
|
||||
CircularProgressIndicator()
|
||||
} else if (operators.isEmpty()) {
|
||||
Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(operators, key = { it.id }) { company ->
|
||||
AssistChip(
|
||||
onClick = { selectedCompanyId = company.id },
|
||||
label = { Text(if (company.id == selectedCompanyId) "✓ ${company.name}" else company.name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val areaId = selectedAreaId ?: return@Button
|
||||
val companyId = selectedCompanyId ?: return@Button
|
||||
scope.launch {
|
||||
opening = true
|
||||
localError = null
|
||||
runCatching { openRepository.open(areaId, companyId) }
|
||||
.onSuccess { opened ->
|
||||
showOpen = false
|
||||
model.openVisit(opened.id)
|
||||
}
|
||||
.onFailure { localError = DhRepository.humanError(it) }
|
||||
opening = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext,
|
||||
) {
|
||||
Text(if (opening) "Abriendo…" else "Abrir inspección ahora")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.busy && model.visits.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
} else if (model.visits.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(model.visits, key = { it.id }) { visit ->
|
||||
MobileVisitCard(visit) { model.openVisit(visit.id) }
|
||||
}
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) {
|
||||
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(visit.code, fontWeight = FontWeight.Bold)
|
||||
Text(visit.status)
|
||||
}
|
||||
Text(visit.operatorCompany?.name ?: "Operadora sin definir")
|
||||
Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall)
|
||||
visit.plannedStartAt?.let {
|
||||
Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Text(
|
||||
"Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+565
@@ -0,0 +1,565 @@
|
||||
package com.korexlabs.dhinspeccion.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Assignment
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.EditNote
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import com.google.android.gms.tasks.CancellationTokenSource
|
||||
import com.korexlabs.dhinspeccion.MainViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private data class ModernActSignatureGeo(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyM: Double?,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ModernMobileActsScreen(
|
||||
model: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onGoInventory: () -> Unit,
|
||||
) {
|
||||
val visit = model.visit ?: return
|
||||
val selected = model.selectedAct
|
||||
val closure = model.actClosure
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
|
||||
var attendance by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") }
|
||||
var fullName by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.fullName.orEmpty()) }
|
||||
var documentType by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentType ?: "DNI") }
|
||||
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) }
|
||||
var position by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.position.orEmpty()) }
|
||||
var email by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.email.orEmpty()) }
|
||||
var phone by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.phone.orEmpty()) }
|
||||
var absenceReason by rememberSaveable(selected?.id) { mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) }
|
||||
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
|
||||
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(visit.id) { model.reloadActs() }
|
||||
|
||||
fun signWithGeo(file: File, company: Boolean) {
|
||||
scope.launch {
|
||||
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
|
||||
if (company) {
|
||||
model.signSelectedActAsCompany(
|
||||
png = file,
|
||||
latitude = geo?.latitude,
|
||||
longitude = geo?.longitude,
|
||||
accuracyM = geo?.accuracyM,
|
||||
manifestation = manifestation,
|
||||
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
|
||||
)
|
||||
} else {
|
||||
model.signSelectedActAsInspector(
|
||||
png = file,
|
||||
latitude = geo?.latitude,
|
||||
longitude = geo?.longitude,
|
||||
accuracyM = geo?.accuracyM,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
ModernHeader(title = "Actas", subtitle = visit.code, onBack = onBack)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 1.dp,
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text(
|
||||
visit.scopeAsset?.name ?: "Yacimiento",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
visit.operatorCompany?.name?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModernMessage(model)
|
||||
|
||||
if (model.acts.isEmpty()) {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(Icons.Filled.Assignment, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Text("Todavía no hay Actas", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Creá la primera Acta y después incorporá Hallazgos seleccionando el Inventario.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Actas de esta inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
model.acts.forEach { act ->
|
||||
val active = selected?.id == act.id
|
||||
Surface(
|
||||
onClick = { model.selectAct(act.id) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = if (active) 1.dp else 0.dp,
|
||||
border = BorderStroke(
|
||||
if (active) 2.dp else 1.dp,
|
||||
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(15.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
when (act.status) {
|
||||
"LOCKED" -> Icons.Filled.Lock
|
||||
"SEALED" -> Icons.Filled.CheckCircle
|
||||
else -> Icons.Filled.EditNote
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(act.code, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"${modernActStatusLabel(act.status)} · ${if (act.urgency == "URGENT") "Urgente" else "No urgente"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"} · ${act.assetCount} elemento${if (act.assetCount == 1) "" else "s"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (active) Icon(Icons.Filled.CheckCircle, "Seleccionada", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
|
||||
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.primaryContainer) {
|
||||
Icon(Icons.Filled.Add, null, Modifier.padding(10.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("Un solo borrador activo por vez", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Urgencia", fontWeight = FontWeight.SemiBold)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = newActUrgency == "NON_URGENT",
|
||||
onClick = { newActUrgency = "NON_URGENT" },
|
||||
label = { Text("No urgente") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = newActUrgency == "URGENT",
|
||||
onClick = { newActUrgency = "URGENT" },
|
||||
label = { Text("Urgente") },
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Text(
|
||||
if (newActUrgency == "URGENT") {
|
||||
"El plazo urgente se computará desde el Acta conforme a la política institucional vigente al bloquearla."
|
||||
} else {
|
||||
"El plazo no urgente comienza con la oficialización GEDO, no al crear el Acta."
|
||||
},
|
||||
Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { model.createAct(newActUrgency) },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, null)
|
||||
Spacer(Modifier.width(7.dp))
|
||||
Text(if (model.busy) "Creando…" else "Crear nueva Acta")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected != null) {
|
||||
HorizontalDivider()
|
||||
Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
StatusPill(modernActStatusLabel(selected.status))
|
||||
}
|
||||
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${selected.findingCount} Hallazgos · ${selected.assetCount} elementos de Inventario", style = MaterialTheme.typography.bodySmall)
|
||||
selected.deadlineAt?.let { Text("Vencimiento · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
|
||||
Text("Vencimiento pendiente de fecha GEDO", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (selected.status) {
|
||||
"DRAFT" -> {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
|
||||
Icon(Icons.Filled.Add, null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Agregar Hallazgo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Person, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = attendance == "PRESENT", onClick = { attendance = "PRESENT" }, label = { Text("Presente") })
|
||||
FilterChip(selected = attendance == "ABSENT", onClick = { attendance = "ABSENT" }, label = { Text("Ausente") })
|
||||
}
|
||||
if (attendance == "PRESENT") {
|
||||
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
|
||||
FilterChip(selected = documentType == kind, onClick = { documentType = kind }, label = { Text(kind) })
|
||||
}
|
||||
}
|
||||
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
Button(
|
||||
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
|
||||
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Guardar responsable") }
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
absenceReason,
|
||||
{ absenceReason = it },
|
||||
label = { Text("Motivo de ausencia *") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
|
||||
enabled = !model.busy && absenceReason.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Guardar ausencia") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text(
|
||||
"Al bloquear el Acta, su contenido y sus Hallazgos quedan inmutables.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
onClick = { model.prepareSelectedAct() },
|
||||
enabled = !model.busy && closure?.responsible != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Finalizar y BLOQUEAR Acta") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"LOCKED" -> {
|
||||
val signatures = closure?.signatures.orEmpty()
|
||||
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
|
||||
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
|
||||
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
|
||||
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Lock, null, tint = MaterialTheme.colorScheme.secondary)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("El contenido ya es inmutable. Restan firmas y manifestaciones para poder sellarla.")
|
||||
closure?.closure?.preparedSha256?.let { Text("Hash · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Firma del inspector", fontWeight = FontWeight.Bold)
|
||||
if (inspectorSigned) {
|
||||
SuccessLine("Firma del inspector registrada")
|
||||
} else {
|
||||
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
SignaturePad(
|
||||
label = "Firmá como inspector/a",
|
||||
enabled = !model.busy,
|
||||
onCaptured = { file -> signWithGeo(file, company = false) },
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
|
||||
if (companyOutcome != null && companyResolved) {
|
||||
val detail = when (companyOutcome.status) {
|
||||
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
|
||||
"REFUSED" -> "Negativa a firmar"
|
||||
else -> companyOutcome.status
|
||||
}
|
||||
SuccessLine(detail)
|
||||
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
|
||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
|
||||
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
|
||||
Text(
|
||||
"La ausencia no resuelve la manifestación. Debe registrarse firma o negativa antes de sellar.",
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(selected = manifestation == "CONFORMITY", onClick = { manifestation = "CONFORMITY" }, label = { Text("Conforme") })
|
||||
FilterChip(selected = manifestation == "DISSENT", onClick = { manifestation = "DISSENT" }, label = { Text("En disidencia") })
|
||||
}
|
||||
if (manifestation == "DISSENT") {
|
||||
OutlinedTextField(
|
||||
dissentStatement,
|
||||
{ dissentStatement = it },
|
||||
label = { Text("Manifestación de disidencia *") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
SignaturePad(
|
||||
label = "Firma del responsable de empresa",
|
||||
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
|
||||
onCaptured = { file -> signWithGeo(file, company = true) },
|
||||
)
|
||||
Text("Si se niega a firmar, registrá el motivo.", style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
refusalReason,
|
||||
{ refusalReason = it },
|
||||
label = { Text("Motivo de negativa") },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
|
||||
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Registrar negativa a firmar") }
|
||||
}
|
||||
|
||||
if (inspectorSigned && companyResolved) {
|
||||
Button(
|
||||
onClick = { model.closeSelectedAct() },
|
||||
enabled = !model.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(Icons.Filled.CheckCircle, null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("SELLAR Acta definitivamente")
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"La Inspección no puede cerrarse hasta completar las manifestaciones de esta Acta.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"SEALED" -> {
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.padding(18.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
|
||||
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
|
||||
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
|
||||
closure?.closure?.finalSha256?.let { Text("SHA-256 · $it", style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"CANCELLED" -> {
|
||||
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Text("Esta Acta fue cancelada y permanece como antecedente.", Modifier.padding(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
|
||||
val activeActs = model.acts.filter { it.status != "CANCELLED" }
|
||||
val pendingActs = activeActs.filter { it.status != "SEALED" }
|
||||
ElevatedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (pendingActs.isEmpty()) {
|
||||
SuccessLine("Todas las Actas están SELLADAS")
|
||||
} else {
|
||||
Text(
|
||||
"Falta SELLAR: ${pendingActs.joinToString { it.code }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = { model.closeInspection() },
|
||||
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cerrar Inspección y salir") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuccessLine(text: String) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text(text, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
private fun modernActStatusLabel(status: String): String = when (status) {
|
||||
"DRAFT" -> "Borrador"
|
||||
"LOCKED" -> "Bloqueada"
|
||||
"SEALED" -> "Sellada"
|
||||
"CANCELLED" -> "Cancelada"
|
||||
else -> status
|
||||
}
|
||||
|
||||
private fun hasModernActLocation(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private suspend fun currentModernActSignatureGeo(context: Context): ModernActSignatureGeo = suspendCancellableCoroutine { continuation ->
|
||||
if (!hasModernActLocation(context)) {
|
||||
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
val source = CancellationTokenSource()
|
||||
try {
|
||||
LocationServices.getFusedLocationProviderClient(context)
|
||||
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
|
||||
.addOnSuccessListener { location ->
|
||||
if (!continuation.isActive) return@addOnSuccessListener
|
||||
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
|
||||
else continuation.resume(ModernActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
|
||||
}
|
||||
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
|
||||
} catch (error: SecurityException) {
|
||||
if (continuation.isActive) continuation.resumeWithException(error)
|
||||
}
|
||||
continuation.invokeOnCancellation { source.cancel() }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Launcher de presentación F6.1 inspirado en el escudo institucional publicado
|
||||
por Gobierno de Mendoza en mendoza.gov.ar. Se mantiene como vector local para
|
||||
evitar el ícono genérico del sistema y no depender de assets remotos.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<!-- fondo institucional -->
|
||||
<path
|
||||
android:fillColor="#0B5FA5"
|
||||
android:pathData="M54,4 A50,50 0,1 0,54 104 A50,50 0,1 0,54 4" />
|
||||
|
||||
<!-- escudo exterior -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,13 C72,13 84,22 87,38 L83,65 C80,81 68,93 54,99 C40,93 28,81 25,65 L21,38 C24,22 36,13 54,13 Z" />
|
||||
|
||||
<!-- campo superior celeste -->
|
||||
<path
|
||||
android:fillColor="#22A9E0"
|
||||
android:pathData="M27,38 C30,27 39,21 54,21 C69,21 78,27 81,38 L78,59 L30,59 Z" />
|
||||
|
||||
<!-- campo inferior dorado -->
|
||||
<path
|
||||
android:fillColor="#C6A66A"
|
||||
android:pathData="M30,63 L78,63 L76,68 C73,80 65,89 54,94 C43,89 35,80 32,68 Z" />
|
||||
|
||||
<!-- gorro frigio simplificado -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M47,31 C52,26 61,25 67,28 C64,31 63,35 64,39 C58,37 52,37 46,40 C44,36 44,33 47,31 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M53,39 L57,39 L56,48 L52,48 Z" />
|
||||
|
||||
<!-- racimo de uva simplificado -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M53,69 C56,69 58,71 58,74 C58,77 56,79 53,79 C50,79 48,77 48,74 C48,71 50,69 53,69 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M45,73 C48,73 50,75 50,78 C50,81 48,83 45,83 C42,83 40,81 40,78 C40,75 42,73 45,73 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M61,73 C64,73 66,75 66,78 C66,81 64,83 61,83 C58,83 56,81 56,78 C56,75 58,73 61,73 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M53,80 C56,80 58,82 58,85 C58,88 56,90 53,90 C50,90 48,88 48,85 C48,82 50,80 53,80 Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M52,67 C54,63 58,61 62,61 C60,65 57,68 53,70 Z" />
|
||||
</vector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
+26
@@ -0,0 +1,26 @@
|
||||
package com.korexlabs.dhinspeccion
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DynamicFieldFlowContractTest {
|
||||
private val source = File("src/main/java/com/korexlabs/dhinspeccion/ui/DynamicVisitRoot.kt").readText()
|
||||
|
||||
@Test
|
||||
fun fastSubinstallationFlowKeepsParentClassificationAndCaptureSteps() {
|
||||
assertTrue(source.contains("Elegí la Instalación padre"))
|
||||
assertTrue(source.contains("Clasificación técnica *"))
|
||||
assertTrue(source.contains("Guardar y tomar foto"))
|
||||
assertTrue(source.contains("model.loadFieldTypes(item.id)"))
|
||||
assertTrue(source.contains("parentId = item.id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fieldCaptureStillRequiresGpsAndPhotoBeforeFindings() {
|
||||
assertTrue(source.contains("La foto sigue siendo obligatoria antes de crear Hallazgos"))
|
||||
assertTrue(source.contains("creationGpsCaptured"))
|
||||
assertTrue(source.contains("fieldPhotoCount == 0"))
|
||||
assertTrue(source.contains("model.uploadFieldPhoto"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.korexlabs.dhinspeccion
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReleaseMetadataTest {
|
||||
@Test
|
||||
fun debugBuildKeepsSeparateApplicationIdentity() {
|
||||
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
|
||||
assertEquals(27, BuildConfig.VERSION_CODE)
|
||||
assertEquals("0.18.0-debug", BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fieldBuildTargetsOnlyTheHttpsProductionApi() {
|
||||
assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL)
|
||||
assertTrue(BuildConfig.API_BASE_URL.startsWith("https://"))
|
||||
}
|
||||
}
|
||||
Generated
+8
-8
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.20.0-2",
|
||||
"version": "0.29.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dhv2-api",
|
||||
"version": "0.20.0-2",
|
||||
"version": "0.29.0-1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
@@ -4166,9 +4166,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -4666,9 +4666,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-api",
|
||||
"version": "0.25.0-1",
|
||||
"version": "0.29.0-1",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
@@ -42,5 +42,9 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.0"
|
||||
},
|
||||
"overrides": {
|
||||
"multer": "2.3.0",
|
||||
"qs": "6.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,20 @@ export interface FieldBriefingAct {
|
||||
findings: FieldBriefingFinding[];
|
||||
}
|
||||
|
||||
function toIsoDate(value: unknown): string {
|
||||
const parsed = value instanceof Date
|
||||
? value
|
||||
: value == null
|
||||
? new Date()
|
||||
: new Date(String(value));
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new Error('La fecha planificada de la inspección no es válida');
|
||||
}
|
||||
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FieldBriefingService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
@@ -67,7 +81,9 @@ export class FieldBriefingService {
|
||||
});
|
||||
}
|
||||
|
||||
const plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
|
||||
// PostgreSQL/pg entrega los timestamptz como Date. String(date).slice(0, 10)
|
||||
// produce textos como "Thu Sep 17", que PostgreSQL rechaza al castear a date.
|
||||
const plannedOn = toIsoDate(visit.plannedStartAt);
|
||||
|
||||
const rows = (await this.dataSource.query(`
|
||||
WITH prior_acts AS (
|
||||
|
||||
@@ -26,11 +26,14 @@ import { InventoryStructureController } from './inventory-structure.controller';
|
||||
import { InventoryStructureService } from './inventory-structure.service';
|
||||
import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller';
|
||||
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
|
||||
import { InventoryTechnicalValuesController } from './inventory-technical-values.controller';
|
||||
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
|
||||
import { InventoryMergeService } from './inventory-merge.service';
|
||||
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
|
||||
import { InventoryFunctionController } from './inventory-function.controller';
|
||||
import { InventoryFunctionService } from './inventory-function.service';
|
||||
import { InventoryBrowserController } from './inventory-browser.controller';
|
||||
import { InventoryBrowserService } from './inventory-browser.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
@@ -39,7 +42,8 @@ import { InventoryFunctionService } from './inventory-function.service';
|
||||
AssetsController,
|
||||
InventoryStructureController,
|
||||
InventoryFamilyCatalogController,
|
||||
InventoryFunctionController,
|
||||
InventoryTechnicalValuesController,
|
||||
InventoryBrowserController,
|
||||
InventoryMergeController,
|
||||
FieldInventoryMergeController,
|
||||
AssetGeometriesController,
|
||||
@@ -56,7 +60,9 @@ import { InventoryFunctionService } from './inventory-function.service';
|
||||
AssetsService,
|
||||
InventoryStructureService,
|
||||
InventoryFamilyCatalogService,
|
||||
InventoryTechnicalValuesService,
|
||||
InventoryFunctionService,
|
||||
InventoryBrowserService,
|
||||
InventoryMergeService,
|
||||
MergedInventoryDossierService,
|
||||
AssetGeometriesService,
|
||||
@@ -71,7 +77,6 @@ import { InventoryFunctionService } from './inventory-function.service';
|
||||
exports: [
|
||||
AssetHistoryService,
|
||||
AssetsService,
|
||||
InventoryFunctionService,
|
||||
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,38 @@ 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 }>;
|
||||
|
||||
// F5/F6: changing the Area operator is a temporal relation event only.
|
||||
// Existing Inventory keeps its creation/historical operator snapshot and
|
||||
// physical hierarchy unchanged. Runtime ownership must resolve this row.
|
||||
const created = await this.loadRelation(manager, row.id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
@@ -170,6 +180,9 @@ export class AssetOperationalRelationsService {
|
||||
entityType: 'area_company_relation',
|
||||
entityId: row.id,
|
||||
afterData: this.auditView(created),
|
||||
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
|
||||
? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true }
|
||||
: undefined,
|
||||
}, manager);
|
||||
return created;
|
||||
});
|
||||
@@ -198,12 +211,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/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
|
||||
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
|
||||
await manager.query(`
|
||||
UPDATE area_company_relations
|
||||
SET valid_until = CURRENT_TIMESTAMP,
|
||||
@@ -220,6 +230,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 +344,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
|
||||
|
||||
@@ -137,21 +137,41 @@ export class AssetTemporalService {
|
||||
if (!row) throw temporalAssetNotFound();
|
||||
const asOf = new Date(query.at);
|
||||
row.asOf = asOf;
|
||||
|
||||
// Physical context is reconstructed from the Inventory history, while the
|
||||
// operator is resolved independently from the temporal Area↔Empresa ledger.
|
||||
// This keeps historical operator changes from rewriting the Inventory.
|
||||
const [context] = (await this.dataSource.query(`
|
||||
SELECT
|
||||
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
|
||||
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea",
|
||||
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany"
|
||||
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 AS "operatorCompany"
|
||||
FROM asset_context_history history
|
||||
LEFT JOIN assets parent ON parent.id=history.parent_id
|
||||
LEFT JOIN assets area ON area.id=history.operational_area_id
|
||||
LEFT JOIN assets company ON company.id=history.operator_company_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT relation.company_id
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.area_id=history.operational_area_id
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_from <= $2
|
||||
AND (relation.valid_until IS NULL OR relation.valid_until > $2)
|
||||
ORDER BY relation.valid_from DESC, relation.created_at DESC, relation.id DESC
|
||||
LIMIT 1
|
||||
) operator_relation ON true
|
||||
LEFT JOIN assets operator_company ON operator_company.id=operator_relation.company_id
|
||||
WHERE history.asset_id=$1
|
||||
AND history.valid_from <= $2
|
||||
AND (history.valid_until IS NULL OR history.valid_until > $2)
|
||||
ORDER BY history.valid_from DESC
|
||||
LIMIT 1
|
||||
`, [assetId, asOf])) as Array<{ parent: Record<string, unknown> | null; operationalArea: Record<string, unknown> | null; operatorCompany: Record<string, unknown> | null }>;
|
||||
`, [assetId, asOf])) as Array<{
|
||||
parent: Record<string, unknown> | null;
|
||||
operationalArea: Record<string, unknown> | null;
|
||||
operatorCompany: Record<string, unknown> | null;
|
||||
}>;
|
||||
if (context && row.snapshot) {
|
||||
row.snapshot = {
|
||||
...row.snapshot,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
|
||||
import { IsDate, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangeAssetContextDto {
|
||||
@IsOptional()
|
||||
@@ -10,8 +10,13 @@ export class ChangeAssetContextDto {
|
||||
@IsUUID('4')
|
||||
operationalAreaId?: string | null;
|
||||
|
||||
/**
|
||||
* Compatibilidad de contrato únicamente. F5/F6 separa la ubicación física
|
||||
* del Inventario de la Operadora temporal del Área, por lo que este valor no
|
||||
* puede modificarse desde un cambio de contexto del Inventario.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
|
||||
operatorCompanyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -54,6 +54,10 @@ export class CreateAssetDto {
|
||||
@IsUUID('4')
|
||||
operatorCompanyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
concessionTypeId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null,
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
// Invariante de dominio: Empresa es un maestro independiente. La jerarquía física
|
||||
// se expresa sólo como Departamento → Área → Yacimiento → Instalación → Subinstalación.
|
||||
// La Empresa y el Tipo de concesión se relacionan directamente con el Yacimiento.
|
||||
export const INVENTORY_STRUCTURE_KINDS = [
|
||||
'EMPRESA',
|
||||
'DEPARTAMENTO',
|
||||
'AREA',
|
||||
'YACIMIENTO',
|
||||
'INSTALACION',
|
||||
@@ -48,6 +53,14 @@ export class CreateInventoryStructureDto {
|
||||
@IsUUID('4')
|
||||
familyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
operatorCompanyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
concessionTypeId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
|
||||
@IsString()
|
||||
|
||||
@@ -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,75 @@
|
||||
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()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsUUID('4', { each: true })
|
||||
parentFamilyIds?: string[];
|
||||
|
||||
@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()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsUUID('4', { each: true })
|
||||
parentFamilyIds?: string[];
|
||||
|
||||
@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,103 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
const DATA_TYPES = ['TEXT','NUMBER','BOOLEAN','DATE','DATETIME','SELECT'] as const;
|
||||
export type InventoryFamilyAttributeDataType = typeof DATA_TYPES[number];
|
||||
|
||||
export class CreateInventoryFamilyAttributeDto {
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
|
||||
@IsString()
|
||||
@Matches(/^[a-z][a-z0-9_]*$/)
|
||||
@MaxLength(80)
|
||||
code!: string;
|
||||
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@IsIn(DATA_TYPES)
|
||||
dataType!: InventoryFamilyAttributeDataType;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRequired?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||
@ValidateIf((_object, value) => value !== null && value !== undefined)
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
unit?: string | null;
|
||||
|
||||
@ValidateIf((object) => object.dataType === 'SELECT')
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(160, { each: true })
|
||||
options?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10000)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpdateInventoryFamilyAttributeDto {
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(DATA_TYPES)
|
||||
dataType?: InventoryFamilyAttributeDataType;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRequired?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
|
||||
@ValidateIf((_object, value) => value !== null && value !== undefined)
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
unit?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(160, { each: true })
|
||||
options?: string[] | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10000)
|
||||
sortOrder?: number;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { IsBoolean, IsEmpty, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
|
||||
|
||||
export class ListAssetTreeQueryDto {
|
||||
@@ -24,8 +24,12 @@ export class ListAssetTreeQueryDto {
|
||||
@IsUUID('4')
|
||||
operationalAreaId?: string;
|
||||
|
||||
/**
|
||||
* Retenido sólo para compatibilidad tipada. El árbol operativo no puede
|
||||
* filtrar por el snapshot histórico de Empresa del Inventario.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
|
||||
operatorCompanyId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -37,4 +41,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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmpty,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
@@ -39,7 +40,6 @@ export class ListAssetsQueryDto {
|
||||
@IsEnum(AssetInformationStatus)
|
||||
status?: AssetInformationStatus;
|
||||
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AssetOperationalStatus)
|
||||
operationalStatus?: AssetOperationalStatus;
|
||||
@@ -54,6 +54,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;
|
||||
@@ -62,7 +67,12 @@ export class ListAssetsQueryDto {
|
||||
@IsUUID('4')
|
||||
operationalAreaId?: string;
|
||||
|
||||
/**
|
||||
* Retenido sólo para compatibilidad tipada con consumidores históricos.
|
||||
* F5/F6 no permite buscar ownership actual por el snapshot de Empresa del
|
||||
* Inventario; la navegación Área↔Operadora usa area_company_relations.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
|
||||
operatorCompanyId?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -48,9 +49,13 @@ export class UpdateAssetDto {
|
||||
operationalAreaId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@IsEmpty({ message: 'La Empresa relacionada se administra en el Yacimiento, no desde la edición genérica del Inventario' })
|
||||
operatorCompanyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmpty({ message: 'El Tipo de concesión se administra en el Yacimiento, no desde la edición genérica del Inventario' })
|
||||
concessionTypeId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : null,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsObject } from 'class-validator';
|
||||
|
||||
export class UpdateInventoryTechnicalValuesDto {
|
||||
@IsObject()
|
||||
values!: Record<string, unknown>;
|
||||
}
|
||||
@@ -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,99 @@
|
||||
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 {
|
||||
CreateInventoryFamilyAttributeDto,
|
||||
UpdateInventoryFamilyAttributeDto,
|
||||
} from './dto/inventory-family-attribute.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);
|
||||
}
|
||||
|
||||
@Get(':familyId/attributes')
|
||||
@RequirePermissions('asset_types.read')
|
||||
attributes(@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string) {
|
||||
return this.families.attributes(familyId);
|
||||
}
|
||||
|
||||
@Post(':familyId/attributes')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
createAttribute(
|
||||
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
|
||||
@Body() dto:CreateInventoryFamilyAttributeDto,
|
||||
@CurrentAuth() principal:AuthPrincipal,
|
||||
@Req() request:RequestWithContext,
|
||||
) {
|
||||
return this.families.createAttribute(familyId,dto,principal,request);
|
||||
}
|
||||
|
||||
@Patch(':familyId/attributes/:attributeId')
|
||||
@RequirePermissions('asset_types.manage')
|
||||
updateAttribute(
|
||||
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
|
||||
@Param('attributeId',new ParseUUIDPipe({version:'4'})) attributeId:string,
|
||||
@Body() dto:UpdateInventoryFamilyAttributeDto,
|
||||
@CurrentAuth() principal:AuthPrincipal,
|
||||
@Req() request:RequestWithContext,
|
||||
) {
|
||||
return this.families.updateAttribute(familyId,attributeId,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,69 @@
|
||||
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';
|
||||
import type {
|
||||
CreateInventoryFamilyAttributeDto,
|
||||
InventoryFamilyAttributeDataType,
|
||||
UpdateInventoryFamilyAttributeDto,
|
||||
} from './dto/inventory-family-attribute.dto';
|
||||
|
||||
type FamilyParent = { id: string; code: string; name: string };
|
||||
type FamilyRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
informationLabels: string[];
|
||||
sourceReference: string | null;
|
||||
isActive: boolean;
|
||||
parentFamilyIds: string[];
|
||||
parentFamilies: FamilyParent[];
|
||||
assetCount: number;
|
||||
findingCount: number;
|
||||
findingItemIds: string[];
|
||||
technicalAttributeCount: number;
|
||||
};
|
||||
type AttributeRow = {
|
||||
id: string;
|
||||
inventoryFamilyId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
dataType: InventoryFamilyAttributeDataType;
|
||||
isRequired: boolean;
|
||||
isActive: boolean;
|
||||
unit: string | null;
|
||||
options: string[] | null;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
@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(this.familySelect(`ORDER BY family.level,family.is_active DESC,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 +77,369 @@ export class InventoryFamilyCatalogService {
|
||||
`, [familyId]);
|
||||
return { family, items, count: items.length };
|
||||
}
|
||||
|
||||
async attributes(familyId: string) {
|
||||
const family = await this.family(familyId, false);
|
||||
const items = await this.attributeRows(this.dataSource.manager, familyId);
|
||||
return { family, items, count: items.length };
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateInventoryFamilyDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const parentIds = await this.validateParents(manager, dto.level, dto.parentFamilyIds ?? [], 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:F6',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');
|
||||
await this.replaceParents(manager, inserted.id, parentIds);
|
||||
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:F6' },
|
||||
},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 nextParentIds = dto.parentFamilyIds === undefined
|
||||
? before.parentFamilyIds
|
||||
: dto.parentFamilyIds;
|
||||
const parentIds = await this.validateParents(manager,before.level,nextParentIds,familyId);
|
||||
if (before.level === 'SUBINSTALLATION' && dto.parentFamilyIds !== undefined) {
|
||||
await this.assertRemovedCompatibilitiesUnused(manager, familyId, parentIds);
|
||||
}
|
||||
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 (dto.parentFamilyIds !== undefined) await this.replaceParents(manager, familyId, parentIds);
|
||||
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 createAttribute(
|
||||
familyId: string,
|
||||
dto: CreateInventoryFamilyAttributeDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.family(familyId,false,manager,true);
|
||||
const options = this.attributeOptions(dto.dataType,dto.options);
|
||||
try {
|
||||
const [created] = (await manager.query(`
|
||||
INSERT INTO inventory_family_attribute_definitions(
|
||||
inventory_family_id,code,name,data_type,is_required,is_active,unit,options,sort_order,created_by,updated_by
|
||||
) VALUES ($1::uuid,$2,$3,$4::asset_attribute_data_type,$5,true,$6,$7::jsonb,$8,$9::uuid,$9::uuid)
|
||||
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
|
||||
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
|
||||
`,[familyId,dto.code,dto.name,dto.dataType,dto.isRequired ?? false,dto.unit ?? null,
|
||||
options === null ? null : JSON.stringify(options),dto.sortOrder ?? 0,principal.userId])) as AttributeRow[];
|
||||
if (!created) throw new Error('No se pudo crear el campo técnico');
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal,request),
|
||||
action: AuditAction.ASSET_UPDATED,
|
||||
entityType:'inventory_family_attribute',entityId:created.id,
|
||||
afterData:created as unknown as Record<string,unknown>,
|
||||
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_CREATED',inventoryFamilyId:familyId},
|
||||
},manager);
|
||||
return created;
|
||||
} catch (error) {
|
||||
if (this.isUniqueViolation(error)) throw new ConflictException({
|
||||
code:'INVENTORY_FAMILY_ATTRIBUTE_CODE_EXISTS',
|
||||
message:'Ya existe un campo técnico con ese código en la clasificación',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateAttribute(
|
||||
familyId: string,
|
||||
attributeId: string,
|
||||
dto: UpdateInventoryFamilyAttributeDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
await this.family(familyId,false,manager,true);
|
||||
const before = await this.attribute(manager,familyId,attributeId,true);
|
||||
const nextType = dto.dataType ?? before.dataType;
|
||||
const nextOptions = dto.options === undefined
|
||||
? this.attributeOptions(nextType,before.options ?? undefined)
|
||||
: this.attributeOptions(nextType,dto.options ?? undefined);
|
||||
const [after] = (await manager.query(`
|
||||
UPDATE inventory_family_attribute_definitions SET
|
||||
name=COALESCE($3::varchar,name),
|
||||
data_type=COALESCE($4::asset_attribute_data_type,data_type),
|
||||
is_required=COALESCE($5::boolean,is_required),
|
||||
is_active=COALESCE($6::boolean,is_active),
|
||||
unit=CASE WHEN $7::boolean THEN $8::varchar ELSE unit END,
|
||||
options=$9::jsonb,
|
||||
sort_order=COALESCE($10::integer,sort_order),
|
||||
updated_by=$11::uuid,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
|
||||
RETURNING id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
|
||||
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
|
||||
`,[attributeId,familyId,dto.name ?? null,dto.dataType ?? null,dto.isRequired ?? null,dto.isActive ?? null,
|
||||
dto.unit !== undefined,dto.unit ?? null,nextOptions === null ? null : JSON.stringify(nextOptions),
|
||||
dto.sortOrder ?? null,principal.userId])) as AttributeRow[];
|
||||
if (!after) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal,request),
|
||||
action: AuditAction.ASSET_UPDATED,
|
||||
entityType:'inventory_family_attribute',entityId:attributeId,
|
||||
beforeData:before as unknown as Record<string,unknown>,afterData:after as unknown as Record<string,unknown>,
|
||||
metadata:{operation:'INVENTORY_FAMILY_ATTRIBUTE_UPDATED',inventoryFamilyId:familyId},
|
||||
},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 familySelect(suffix:string) {
|
||||
return `
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
family.information_labels AS "informationLabels",
|
||||
family.source_reference AS "sourceReference",family.is_active AS "isActive",
|
||||
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies",
|
||||
(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",
|
||||
(SELECT COUNT(*)::integer FROM inventory_family_attribute_definitions definition
|
||||
WHERE definition.inventory_family_id=family.id AND definition.is_active=true) AS "technicalAttributeCount"
|
||||
FROM inventory_families family
|
||||
${suffix}
|
||||
`;
|
||||
}
|
||||
|
||||
private async family(
|
||||
familyId:string,
|
||||
activeOnly:boolean,
|
||||
manager:EntityManager=this.dataSource.manager,
|
||||
lock=false,
|
||||
):Promise<FamilyRow> {
|
||||
const lockClause=lock ? 'FOR UPDATE OF family' : '';
|
||||
const rows=(await manager.query(this.familySelect(`
|
||||
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
|
||||
${lockClause}
|
||||
`),[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 validateParents(
|
||||
manager:EntityManager,
|
||||
level:'INSTALLATION'|'SUBINSTALLATION',
|
||||
requestedIds:string[],
|
||||
ownId:string|null,
|
||||
):Promise<string[]> {
|
||||
const ids=[...new Set(requestedIds)];
|
||||
if (level==='INSTALLATION') {
|
||||
if (ids.length) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
|
||||
message:'Una clasificación de Instalación no lleva compatibilidades padre',
|
||||
});
|
||||
return [];
|
||||
}
|
||||
if (!ids.length) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
|
||||
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
|
||||
});
|
||||
if (ownId && ids.includes(ownId)) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser compatible consigo misma',
|
||||
});
|
||||
const [count]=(await manager.query(`
|
||||
SELECT COUNT(*)::integer AS total FROM inventory_families
|
||||
WHERE id=ANY($1::uuid[]) AND level='INSTALLATION' AND is_active=true
|
||||
`,[ids])) as Array<{total:number}>;
|
||||
if (Number(count?.total ?? 0)!==ids.length) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_PARENT_INVALID',
|
||||
message:'Todas las compatibilidades deben ser clasificaciones de Instalación activas',
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async replaceParents(manager:EntityManager,familyId:string,parentIds:string[]) {
|
||||
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
|
||||
if (parentIds.length) await manager.query(`
|
||||
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
|
||||
SELECT $1::uuid,parent_id FROM UNNEST($2::uuid[]) AS selected(parent_id)
|
||||
ON CONFLICT (child_family_id,parent_family_id) DO NOTHING
|
||||
`,[familyId,parentIds]);
|
||||
}
|
||||
|
||||
private async assertRemovedCompatibilitiesUnused(manager:EntityManager,childFamilyId:string,nextParentIds:string[]) {
|
||||
const rows=await manager.query(`
|
||||
SELECT child.name AS "childName",parent.name AS "parentName",COUNT(*)::integer AS total
|
||||
FROM assets child
|
||||
JOIN assets parent ON parent.id=child.parent_id
|
||||
WHERE child.inventory_family_id=$1::uuid
|
||||
AND parent.inventory_family_id IS NOT NULL
|
||||
AND NOT (parent.inventory_family_id=ANY($2::uuid[]))
|
||||
GROUP BY child.name,parent.name
|
||||
ORDER BY total DESC
|
||||
LIMIT 1
|
||||
`,[childFamilyId,nextParentIds]);
|
||||
if (rows[0]) throw new ConflictException({
|
||||
code:'INVENTORY_FAMILY_COMPATIBILITY_IN_USE',
|
||||
message:`No podés quitar esa compatibilidad: ya existen Subinstalaciones de este tipo dentro de ${rows[0].parentName}`,
|
||||
});
|
||||
}
|
||||
|
||||
private async attributeRows(manager:EntityManager,familyId:string):Promise<AttributeRow[]> {
|
||||
return manager.query(`
|
||||
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
|
||||
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
|
||||
FROM inventory_family_attribute_definitions
|
||||
WHERE inventory_family_id=$1::uuid
|
||||
ORDER BY is_active DESC,sort_order,name,code
|
||||
`,[familyId]) as Promise<AttributeRow[]>;
|
||||
}
|
||||
|
||||
private async attribute(manager:EntityManager,familyId:string,attributeId:string,lock=false):Promise<AttributeRow> {
|
||||
const rows=(await manager.query(`
|
||||
SELECT id,inventory_family_id AS "inventoryFamilyId",code,name,data_type AS "dataType",
|
||||
is_required AS "isRequired",is_active AS "isActive",unit,options,sort_order AS "sortOrder"
|
||||
FROM inventory_family_attribute_definitions
|
||||
WHERE id=$1::uuid AND inventory_family_id=$2::uuid
|
||||
${lock ? 'FOR UPDATE' : ''}
|
||||
`,[attributeId,familyId])) as AttributeRow[];
|
||||
if (!rows[0]) throw new NotFoundException({code:'INVENTORY_FAMILY_ATTRIBUTE_NOT_FOUND',message:'El campo técnico no existe'});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private attributeOptions(type:InventoryFamilyAttributeDataType,raw:string[]|undefined):string[]|null {
|
||||
if (type!=='SELECT') return null;
|
||||
const values=[...new Map((raw ?? []).map((value) => {
|
||||
const clean=value.trim(); return [clean.toLocaleLowerCase('es-AR'),clean] as const;
|
||||
}).filter(([,value]) => Boolean(value))).values()];
|
||||
if (!values.length) throw new BadRequestException({
|
||||
code:'INVENTORY_FAMILY_ATTRIBUTE_OPTIONS_REQUIRED',
|
||||
message:'Un campo de lista necesita al menos una opción',
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
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()];
|
||||
}
|
||||
|
||||
private isUniqueViolation(error:unknown) {
|
||||
return Boolean(error && typeof error==='object' && 'code' in error && (error as {code?:string}).code==='23505');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -20,6 +20,8 @@ import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/
|
||||
import { AssetHistoryService } from './asset-history.service';
|
||||
|
||||
type StructureTypeRow = { id: string; code: string; name: string };
|
||||
type SimpleOptionRow = { id: string; code: string; name: string };
|
||||
type FamilyParent = { id: string; code: string; name: string };
|
||||
type FamilyRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -27,28 +29,37 @@ type FamilyRow = {
|
||||
level: 'INSTALLATION' | 'SUBINSTALLATION';
|
||||
legacyTypeCode: string | null;
|
||||
informationLabels: string[];
|
||||
parentFamilyId: string | null;
|
||||
parentFamilyCode: string | null;
|
||||
parentFamilyName: string | null;
|
||||
parentFamilyIds: string[];
|
||||
parentFamilies: FamilyParent[];
|
||||
};
|
||||
type ParentRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
typeCode: string;
|
||||
inventoryFamilyId: string | null;
|
||||
operationalAreaId: string | null;
|
||||
operatorCompanyId: string | null;
|
||||
inventoryFamilyId: string | null;
|
||||
};
|
||||
type IdRow = { id: string };
|
||||
|
||||
type YacimientoContext = {
|
||||
companyId: string;
|
||||
concessionTypeId: string;
|
||||
concessionName: 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,36 +81,76 @@ 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",
|
||||
family.information_labels AS "informationLabels",
|
||||
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
|
||||
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
|
||||
FROM inventory_families family
|
||||
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
|
||||
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE family.is_active=true
|
||||
ORDER BY family.level,family.name,family.code
|
||||
`)) as FamilyRow[];
|
||||
|
||||
const companies = (await this.dataSource.query(`
|
||||
SELECT asset.id,asset.code,COALESCE(profile.legal_name,asset.name) AS name
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id AND type.operational_role='COMPANY' AND type.is_active=true
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=asset.id
|
||||
WHERE asset.information_status<>'INACTIVE'
|
||||
ORDER BY COALESCE(profile.legal_name,asset.name),asset.code
|
||||
`)) as SimpleOptionRow[];
|
||||
const concessionTypes = (await this.dataSource.query(`
|
||||
SELECT id,code,name
|
||||
FROM concession_types
|
||||
WHERE is_active=true
|
||||
ORDER BY CASE lower(name) WHEN 'explotación' THEN 1 WHEN 'exploración' THEN 2 ELSE 9 END,name,code
|
||||
`)) as SimpleOptionRow[];
|
||||
|
||||
return {
|
||||
levels: [
|
||||
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false },
|
||||
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
|
||||
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
|
||||
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
|
||||
independentMasters: [
|
||||
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
|
||||
],
|
||||
levels: [
|
||||
{ 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 },
|
||||
],
|
||||
companies,
|
||||
concessionTypes,
|
||||
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
|
||||
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
|
||||
};
|
||||
@@ -107,7 +158,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 +190,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,47 +205,82 @@ 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 yacimientoContext = await this.requireYacimientoContext(manager, dto);
|
||||
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 operatorCompanyId = dto.kind === 'YACIMIENTO'
|
||||
? yacimientoContext?.companyId ?? null
|
||||
: dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION'
|
||||
? parent?.operatorCompanyId ?? null
|
||||
: null;
|
||||
const concessionTypeId = dto.kind === 'YACIMIENTO'
|
||||
? yacimientoContext?.concessionTypeId ?? null
|
||||
: null;
|
||||
|
||||
if ((dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') && !operatorCompanyId) {
|
||||
throw new ConflictException({
|
||||
code: 'INVENTORY_YACIMIENTO_COMPANY_MISSING',
|
||||
message: 'El Yacimiento de origen no tiene una Empresa relacionada válida',
|
||||
});
|
||||
}
|
||||
|
||||
const inserted = (await manager.query(`
|
||||
INSERT INTO assets (
|
||||
asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
|
||||
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
|
||||
code,name,common_name,description,information_status,operational_status,
|
||||
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
|
||||
) VALUES (
|
||||
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,
|
||||
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status,
|
||||
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid
|
||||
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid,
|
||||
$7::varchar,$8::varchar,$9::varchar,$10::text,$11::asset_information_status,$12::asset_operational_status,
|
||||
$13::varchar,$14::varchar,$15::varchar,$16::text,$17::uuid,$17::uuid,$17::uuid
|
||||
) RETURNING id
|
||||
`, [
|
||||
type.id,
|
||||
parent?.id ?? null,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
concessionTypeId,
|
||||
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' : 'Estructura manual de Inventario',
|
||||
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
|
||||
family
|
||||
? `Clasificación técnica: ${family.code} · ${family.name}`
|
||||
: yacimientoContext
|
||||
? `Tipo de concesión: ${yacimientoContext.concessionName}`
|
||||
: 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]);
|
||||
}
|
||||
if (dto.kind === 'YACIMIENTO' && operationalAreaId && operatorCompanyId && concessionTypeId) {
|
||||
await this.ensureCompatibilityProjection(
|
||||
manager,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
concessionTypeId,
|
||||
yacimientoContext?.concessionName ?? 'Concesión',
|
||||
);
|
||||
}
|
||||
|
||||
const versionNumber = await this.history.capture(
|
||||
manager,
|
||||
id,
|
||||
AssetVersionChangeType.CREATED,
|
||||
principal,
|
||||
request,
|
||||
manager,id,AssetVersionChangeType.CREATED,principal,request,
|
||||
);
|
||||
await manager.query(`
|
||||
INSERT INTO asset_context_history (
|
||||
@@ -202,53 +288,46 @@ export class InventoryStructureService {
|
||||
change_reason,asset_version_number,source,request_id,created_by
|
||||
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
|
||||
`, [
|
||||
id,
|
||||
parent?.id ?? null,
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
'Alta guiada de Inventario estructural F3.1',
|
||||
versionNumber,
|
||||
request.requestId,
|
||||
principal.userId,
|
||||
id,parent?.id ?? null,operationalAreaId,operatorCompanyId,
|
||||
dto.kind === 'YACIMIENTO'
|
||||
? 'Alta manual de Yacimiento con Área, Empresa y Tipo de concesión'
|
||||
: dto.kind === 'EMPRESA'
|
||||
? 'Alta manual de Empresa independiente'
|
||||
: 'Alta manual de estructura de Inventario',
|
||||
versionNumber,request.requestId,principal.userId,
|
||||
]);
|
||||
const created = await this.loadView(manager, id);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.ASSET_CREATED,
|
||||
entityType: 'asset',
|
||||
entityId: id,
|
||||
entityType: 'asset',entityId: id,
|
||||
afterData: created as unknown as Record<string, unknown>,
|
||||
metadata: {
|
||||
versionNumber,
|
||||
inventoryStructureKind: dto.kind,
|
||||
inventoryFamilyId: family?.id ?? null,
|
||||
inventoryFamilyCode: family?.code ?? null,
|
||||
versionNumber,inventoryStructureKind: dto.kind,
|
||||
inventoryFamilyId: family?.id ?? null,inventoryFamilyCode: family?.code ?? null,
|
||||
operatorCompanyId,
|
||||
concessionTypeId,
|
||||
},
|
||||
}, manager);
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({
|
||||
code: 'ASSET_CODE_ALREADY_EXISTS',
|
||||
message: 'Ya existe un registro de Inventario con ese código',
|
||||
});
|
||||
}
|
||||
if (isUniqueViolation(error)) throw new ConflictException({
|
||||
code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro con ese código',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
|
||||
const rows = (await manager.query(`
|
||||
SELECT id,code,name FROM asset_types
|
||||
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1
|
||||
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[];
|
||||
if (!rows[0]) {
|
||||
throw new ConflictException({
|
||||
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
|
||||
message: `El nivel ${kind} no está configurado`,
|
||||
});
|
||||
}
|
||||
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',message: `El nivel ${kind} no está configurado`,
|
||||
});
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
@@ -259,25 +338,23 @@ export class InventoryStructureService {
|
||||
): Promise<ParentRow | null> {
|
||||
const expectedType = PARENT_TYPE_BY_KIND[kind];
|
||||
if (!expectedType) {
|
||||
if (parentId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_AREA_MUST_BE_ROOT',
|
||||
message: 'Un Área se crea como registro raíz y no puede tener padre',
|
||||
});
|
||||
}
|
||||
if (parentId) throw new BadRequestException({
|
||||
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;
|
||||
}
|
||||
if (!parentId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
|
||||
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
|
||||
});
|
||||
}
|
||||
if (!parentId) throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_REQUIRED',
|
||||
message: `Para crear ${kind.toLowerCase()} primero tenés que elegir su ${expectedType}`,
|
||||
});
|
||||
const rows = (await manager.query(`
|
||||
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
||||
asset.inventory_family_id AS "inventoryFamilyId",
|
||||
asset.operational_area_id AS "operationalAreaId",
|
||||
asset.operator_company_id AS "operatorCompanyId",
|
||||
asset.inventory_family_id AS "inventoryFamilyId"
|
||||
asset.operator_company_id AS "operatorCompanyId"
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
|
||||
@@ -285,13 +362,90 @@ export class InventoryStructureService {
|
||||
`, [parentId])) as ParentRow[];
|
||||
const parent = rows[0];
|
||||
if (!parent) throw new NotFoundException({ code: 'INVENTORY_STRUCTURE_PARENT_NOT_FOUND', message: 'El registro padre no existe' });
|
||||
if (parent.typeCode.toLowerCase() !== expectedType) {
|
||||
if (parent.typeCode.toLowerCase() !== expectedType) throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
|
||||
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
|
||||
});
|
||||
return parent;
|
||||
}
|
||||
|
||||
private async requireYacimientoContext(
|
||||
manager: EntityManager,
|
||||
dto: CreateInventoryStructureDto,
|
||||
): Promise<YacimientoContext | null> {
|
||||
if (dto.kind !== 'YACIMIENTO') {
|
||||
if (dto.operatorCompanyId || dto.concessionTypeId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_YACIMIENTO_CONTEXT_NOT_ALLOWED',
|
||||
message: 'Empresa relacionada y Tipo de concesión sólo corresponden al Yacimiento',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!dto.operatorCompanyId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
|
||||
message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación',
|
||||
code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED',
|
||||
message: 'Seleccioná la Empresa relacionada del Yacimiento',
|
||||
});
|
||||
}
|
||||
return parent;
|
||||
if (!dto.concessionTypeId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INVENTORY_YACIMIENTO_CONCESSION_REQUIRED',
|
||||
message: 'Seleccioná el Tipo de concesión del Yacimiento',
|
||||
});
|
||||
}
|
||||
const companyRows = (await manager.query(`
|
||||
SELECT asset.id
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE asset.id=$1::uuid
|
||||
AND type.operational_role='COMPANY'
|
||||
AND type.is_active=true
|
||||
AND asset.information_status<>'INACTIVE'
|
||||
FOR KEY SHARE
|
||||
`,[dto.operatorCompanyId])) as IdRow[];
|
||||
if (!companyRows[0]) throw new BadRequestException({
|
||||
code: 'INVENTORY_YACIMIENTO_COMPANY_INVALID',
|
||||
message: 'La Empresa relacionada seleccionada no es válida',
|
||||
});
|
||||
const concessionRows = (await manager.query(`
|
||||
SELECT id,name
|
||||
FROM concession_types
|
||||
WHERE id=$1::uuid AND is_active=true
|
||||
FOR KEY SHARE
|
||||
`,[dto.concessionTypeId])) as Array<{id:string;name:string}>;
|
||||
if (!concessionRows[0]) throw new BadRequestException({
|
||||
code: 'INVENTORY_YACIMIENTO_CONCESSION_INVALID',
|
||||
message: 'El Tipo de concesión seleccionado no es válido',
|
||||
});
|
||||
return {
|
||||
companyId: dto.operatorCompanyId,
|
||||
concessionTypeId: dto.concessionTypeId,
|
||||
concessionName: concessionRows[0].name,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
|
||||
if (parent.typeCode.toLowerCase()==='area') return parent.id;
|
||||
if (parent.operationalAreaId) return parent.operationalAreaId;
|
||||
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(
|
||||
@@ -304,49 +458,93 @@ 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,
|
||||
family.legacy_type_code AS "legacyTypeCode",
|
||||
family.information_labels AS "informationLabels",
|
||||
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
|
||||
family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
|
||||
COALESCE((SELECT JSONB_AGG(rule.parent_family_id ORDER BY rule.parent_family_id)
|
||||
FROM inventory_family_parent_rules rule WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilyIds",
|
||||
COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) ORDER BY parent.name,parent.code)
|
||||
FROM inventory_family_parent_rules rule JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE rule.child_family_id=family.id),'[]'::jsonb) AS "parentFamilies"
|
||||
FROM inventory_families family
|
||||
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
|
||||
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
|
||||
WHERE family.id=$1::uuid AND family.is_active=true
|
||||
LIMIT 1
|
||||
`, [familyId])) as FamilyRow[];
|
||||
const family = rows[0];
|
||||
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' });
|
||||
if (!family) 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({
|
||||
if (kind === 'SUBINSTALACION') {
|
||||
if (!parent?.inventoryFamilyId) throw new BadRequestException({
|
||||
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
|
||||
message:'La Instalación padre debe tener una clasificación técnica válida',
|
||||
});
|
||||
const [compatible]=(await manager.query(`
|
||||
SELECT 1 AS ok FROM inventory_family_parent_rules
|
||||
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
|
||||
LIMIT 1
|
||||
`,[family.id,parent.inventoryFamilyId])) as Array<{ok:number}>;
|
||||
if (!compatible) 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: 'Ese tipo de Subinstalación no es compatible con la clasificación de la Instalación seleccionada',
|
||||
});
|
||||
}
|
||||
return family;
|
||||
}
|
||||
|
||||
private async ensureCompatibilityProjection(
|
||||
manager: EntityManager,
|
||||
areaId: string,
|
||||
companyId: string,
|
||||
concessionTypeId: string,
|
||||
concessionName: string,
|
||||
): Promise<void> {
|
||||
const companyProjection = await manager.query(`
|
||||
SELECT id FROM area_company_relations
|
||||
WHERE area_id=$1::uuid AND company_id=$2::uuid AND relation_role='OPERATOR' AND valid_to IS NULL
|
||||
LIMIT 1
|
||||
`,[areaId,companyId]);
|
||||
if (!companyProjection[0]) {
|
||||
await manager.query(`
|
||||
INSERT INTO area_company_relations(area_id,company_id,relation_role,valid_from,start_reason)
|
||||
VALUES($1::uuid,$2::uuid,'OPERATOR',CURRENT_TIMESTAMP,'Proyección derivada de Yacimiento')
|
||||
`,[areaId,companyId]);
|
||||
}
|
||||
const rightType = concessionName.toLocaleLowerCase('es-AR').includes('explor')
|
||||
? 'EXPLORATION_PERMIT'
|
||||
: 'EXPLOITATION_CONCESSION';
|
||||
const rightProjection = await manager.query(`
|
||||
SELECT id FROM area_legal_rights
|
||||
WHERE area_id=$1::uuid AND right_type=$2::area_legal_right_type AND status='ACTIVE'
|
||||
LIMIT 1
|
||||
`,[areaId,rightType]);
|
||||
if (!rightProjection[0]) {
|
||||
await manager.query(`
|
||||
INSERT INTO area_legal_rights(area_id,right_type,name,status,notes)
|
||||
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4)
|
||||
`,[areaId,rightType,`${concessionName} · proyección de Yacimiento`,`Tipo de concesión canónico: ${concessionTypeId}`]);
|
||||
}
|
||||
}
|
||||
|
||||
private generatedCode(kind: InventoryStructureKind, name: string): string {
|
||||
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
|
||||
const readable = name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || 'REGISTRO';
|
||||
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, '').toUpperCase()
|
||||
.replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO';
|
||||
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
|
||||
}
|
||||
|
||||
@@ -356,6 +554,9 @@ 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 company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany",
|
||||
CASE WHEN concession.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',concession.id,'code',concession.code,'name',concession.name) END AS "concessionType",
|
||||
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 +565,10 @@ 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 assets company ON company.id=asset.operator_company_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
LEFT JOIN concession_types concession ON concession.id=asset.concession_type_id
|
||||
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||
WHERE asset.id=$1::uuid
|
||||
`, [id]);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, 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 { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
|
||||
import { InventoryTechnicalValuesService } from './inventory-technical-values.service';
|
||||
|
||||
@Controller('assets/:assetId/technical-values')
|
||||
export class InventoryTechnicalValuesController {
|
||||
constructor(private readonly technical:InventoryTechnicalValuesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('assets.read')
|
||||
get(@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string) {
|
||||
return this.technical.get(assetId);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermissions('assets.update')
|
||||
replace(
|
||||
@Param('assetId',new ParseUUIDPipe({version:'4'})) assetId:string,
|
||||
@Body() dto:UpdateInventoryTechnicalValuesDto,
|
||||
@CurrentAuth() principal:AuthPrincipal,
|
||||
@Req() request:RequestWithContext,
|
||||
) {
|
||||
return this.technical.replace(assetId,dto,principal,request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { administrationAuditContext } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { AuditAction } from '../database/entities';
|
||||
import type { UpdateInventoryTechnicalValuesDto } from './dto/update-inventory-technical-values.dto';
|
||||
|
||||
type DefinitionRow = {
|
||||
id:string; code:string; name:string; dataType:'TEXT'|'NUMBER'|'BOOLEAN'|'DATE'|'DATETIME'|'SELECT';
|
||||
isRequired:boolean; isActive:boolean; unit:string|null; options:string[]|null; sortOrder:number;
|
||||
};
|
||||
type AssetRow = { id:string; inventoryFamilyId:string|null; familyCode:string|null; familyName:string|null; familyLevel:string|null };
|
||||
|
||||
@Injectable()
|
||||
export class InventoryTechnicalValuesService {
|
||||
constructor(private readonly dataSource:DataSource,private readonly audit:AuditService) {}
|
||||
|
||||
async get(assetId:string) {
|
||||
return this.load(this.dataSource.manager,assetId);
|
||||
}
|
||||
|
||||
async replace(assetId:string,dto:UpdateInventoryTechnicalValuesDto,principal:AuthPrincipal,request:RequestWithContext) {
|
||||
return this.dataSource.transaction(async(manager) => {
|
||||
const before=await this.load(manager,assetId,true);
|
||||
const definitions=before.definitions as DefinitionRow[];
|
||||
const definitionById=new Map(definitions.map((definition)=>[definition.id,definition]));
|
||||
const normalized:Record<string,unknown>={};
|
||||
for (const [definitionId,raw] of Object.entries(dto.values)) {
|
||||
const definition=definitionById.get(definitionId);
|
||||
if (!definition || !definition.isActive) throw new BadRequestException({
|
||||
code:'INVENTORY_TECHNICAL_FIELD_INVALID',message:'Uno o más campos técnicos no pertenecen a la clasificación actual',
|
||||
});
|
||||
const value=this.normalize(definition,raw);
|
||||
if (value!==undefined) normalized[definitionId]=value;
|
||||
}
|
||||
for (const definition of definitions.filter((item)=>item.isActive && item.isRequired)) {
|
||||
if (!(definition.id in normalized)) throw new BadRequestException({
|
||||
code:'INVENTORY_TECHNICAL_FIELD_REQUIRED',message:`Completá el campo técnico obligatorio: ${definition.name}`,
|
||||
});
|
||||
}
|
||||
await manager.query('DELETE FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid',[assetId]);
|
||||
const entries=Object.entries(normalized);
|
||||
if (entries.length) await manager.query(`
|
||||
INSERT INTO asset_inventory_attribute_values(asset_id,definition_id,value,updated_by)
|
||||
SELECT $1::uuid,item.definition_id,item.value,$3::uuid
|
||||
FROM JSONB_TO_RECORDSET($2::jsonb) AS item(definition_id uuid,value jsonb)
|
||||
`,[assetId,JSON.stringify(entries.map(([definition_id,value])=>({definition_id,value}))),principal.userId]);
|
||||
const after=await this.load(manager,assetId);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal,request),action:AuditAction.ASSET_UPDATED,
|
||||
entityType:'asset_inventory_technical_values',entityId:assetId,
|
||||
beforeData:{values:before.values},afterData:{values:after.values},
|
||||
metadata:{operation:'INVENTORY_TECHNICAL_VALUES_REPLACED',inventoryFamilyId:after.family.id},
|
||||
},manager);
|
||||
return after;
|
||||
});
|
||||
}
|
||||
|
||||
private async load(manager:EntityManager,assetId:string,lock=false) {
|
||||
const rows=(await manager.query(`
|
||||
SELECT asset.id,asset.inventory_family_id AS "inventoryFamilyId",
|
||||
family.code AS "familyCode",family.name AS "familyName",family.level::text AS "familyLevel"
|
||||
FROM assets asset LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
||||
WHERE asset.id=$1::uuid ${lock ? 'FOR UPDATE OF asset' : ''}
|
||||
`,[assetId])) as AssetRow[];
|
||||
const asset=rows[0];
|
||||
if (!asset) throw new NotFoundException({code:'ASSET_NOT_FOUND',message:'El registro de Inventario no existe'});
|
||||
if (!asset.inventoryFamilyId || !asset.familyCode || !asset.familyName || !asset.familyLevel) throw new BadRequestException({
|
||||
code:'INVENTORY_TECHNICAL_FAMILY_REQUIRED',message:'Este nivel no tiene clasificación técnica y no admite campos técnicos por rubro',
|
||||
});
|
||||
const definitions=(await manager.query(`
|
||||
SELECT id,code,name,data_type AS "dataType",is_required AS "isRequired",is_active AS "isActive",
|
||||
unit,options,sort_order AS "sortOrder"
|
||||
FROM inventory_family_attribute_definitions
|
||||
WHERE inventory_family_id=$1::uuid
|
||||
ORDER BY is_active DESC,sort_order,name,code
|
||||
`,[asset.inventoryFamilyId])) as DefinitionRow[];
|
||||
const valueRows=await manager.query(`
|
||||
SELECT definition_id AS id,value FROM asset_inventory_attribute_values WHERE asset_id=$1::uuid
|
||||
`,[assetId]) as Array<{id:string;value:unknown}>;
|
||||
return {
|
||||
assetId,
|
||||
family:{id:asset.inventoryFamilyId,code:asset.familyCode,name:asset.familyName,level:asset.familyLevel},
|
||||
definitions,
|
||||
values:Object.fromEntries(valueRows.map((row)=>[row.id,row.value])),
|
||||
};
|
||||
}
|
||||
|
||||
private normalize(definition:DefinitionRow,raw:unknown):unknown|undefined {
|
||||
if (raw===null || raw===undefined || raw==='') return undefined;
|
||||
switch(definition.dataType) {
|
||||
case 'TEXT': {
|
||||
if (typeof raw!=='string') return this.invalid(definition);
|
||||
const value=raw.trim(); if (!value) return undefined; if (value.length>4000) return this.invalid(definition); return value;
|
||||
}
|
||||
case 'NUMBER': {
|
||||
const value=typeof raw==='number' ? raw : typeof raw==='string' ? Number(raw) : Number.NaN;
|
||||
if (!Number.isFinite(value)) return this.invalid(definition); return value;
|
||||
}
|
||||
case 'BOOLEAN': if (typeof raw!=='boolean') return this.invalid(definition); return raw;
|
||||
case 'DATE': {
|
||||
if (typeof raw!=='string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw) || Number.isNaN(Date.parse(`${raw}T00:00:00Z`))) return this.invalid(definition);
|
||||
return raw;
|
||||
}
|
||||
case 'DATETIME': {
|
||||
if (typeof raw!=='string' || Number.isNaN(Date.parse(raw))) return this.invalid(definition); return new Date(raw).toISOString();
|
||||
}
|
||||
case 'SELECT': {
|
||||
if (typeof raw!=='string' || !definition.options?.includes(raw)) return this.invalid(definition); return raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private invalid(definition:DefinitionRow):never {
|
||||
throw new BadRequestException({code:'INVENTORY_TECHNICAL_VALUE_INVALID',message:`Valor inválido para ${definition.name}`});
|
||||
}
|
||||
}
|
||||
@@ -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,6 +19,7 @@ interface DashboardCountsRow {
|
||||
inactiveUsers: number | string;
|
||||
activeSessions: number | string;
|
||||
openFindings: number | string;
|
||||
actsInFollowUp: number | string;
|
||||
findingsWithoutControlDate: number | string;
|
||||
overdueControls: number | string;
|
||||
controlsNext30Days: number | string;
|
||||
@@ -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,9 +67,22 @@ 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 finding
|
||||
@@ -115,7 +134,7 @@ export class DashboardService {
|
||||
) AS "sealedActsWithoutReport"
|
||||
`)) as DashboardCountsRow[];
|
||||
|
||||
const recentAudit = (await manager.query(`
|
||||
const recentInspectorActivity = (await manager.query(`
|
||||
SELECT
|
||||
event.id,
|
||||
event.occurred_at AS "occurredAt",
|
||||
@@ -124,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
|
||||
@@ -168,6 +203,7 @@ export class DashboardService {
|
||||
inactiveUsers: Number(counts?.inactiveUsers ?? 0),
|
||||
activeSessions: Number(counts?.activeSessions ?? 0),
|
||||
openFindings: Number(counts?.openFindings ?? 0),
|
||||
actsInFollowUp: Number(counts?.actsInFollowUp ?? 0),
|
||||
findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
|
||||
overdueControls: Number(counts?.overdueControls ?? 0),
|
||||
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
|
||||
@@ -175,7 +211,7 @@ export class DashboardService {
|
||||
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
|
||||
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
|
||||
},
|
||||
recentAudit,
|
||||
recentInspectorActivity,
|
||||
upcomingControls,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ export enum AssetDataOrigin {
|
||||
@Index('idx_assets_parent_id', ['parentId'])
|
||||
@Index('idx_assets_operational_area_id', ['operationalAreaId'])
|
||||
@Index('idx_assets_operator_company_id', ['operatorCompanyId'])
|
||||
@Index('idx_assets_concession_type_id', ['concessionTypeId'])
|
||||
@Index('idx_assets_inventory_family_id', ['inventoryFamilyId'])
|
||||
@Index('idx_assets_information_status', ['informationStatus'])
|
||||
@Index('idx_assets_operational_status', ['operationalStatus'])
|
||||
@@ -54,9 +55,15 @@ export class Asset extends TimestampedEntity {
|
||||
@Column({ name: 'operator_company_id', type: 'uuid', nullable: true })
|
||||
operatorCompanyId!: string | null;
|
||||
|
||||
@Column({ name: 'concession_type_id', type: 'uuid', nullable: true })
|
||||
concessionTypeId!: string | null;
|
||||
|
||||
@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;
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ export enum InspectionDeadlineBasis {
|
||||
GEDO_DATE = 'GEDO_DATE',
|
||||
}
|
||||
|
||||
// Una Inspección puede acumular múltiples Actas. El índice parcial de DRAFT es
|
||||
// deliberado: permite el historial multi-Acta y bloquea dos borradores simultáneos.
|
||||
@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 })
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F6SolidInventoryModel1790091000000 implements MigrationInterface {
|
||||
name = 'F6SolidInventoryModel1790091000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// A technical Subinstallation family may be valid under several Installation
|
||||
// families. F3.1 used child_family_id as the PK, which made this relation
|
||||
// accidentally one-to-one from the child's point of view.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE constraint_name text;
|
||||
BEGIN
|
||||
SELECT con.conname INTO constraint_name
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid=con.conrelid
|
||||
WHERE rel.relname='inventory_family_parent_rules' AND con.contype='p'
|
||||
LIMIT 1;
|
||||
IF constraint_name IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT %I',constraint_name);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inventory_family_parent_rules
|
||||
ADD CONSTRAINT pk_inventory_family_parent_rules
|
||||
PRIMARY KEY (child_family_id,parent_family_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_family_parent_rules_parent
|
||||
ON inventory_family_parent_rules(parent_family_id,child_family_id)
|
||||
`);
|
||||
|
||||
// Protect compatibility semantics at database level.
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_inventory_family_compatibility_rule()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE child_level text; parent_level text;
|
||||
BEGIN
|
||||
SELECT level::text INTO child_level FROM inventory_families WHERE id=NEW.child_family_id;
|
||||
SELECT level::text INTO parent_level FROM inventory_families WHERE id=NEW.parent_family_id;
|
||||
IF child_level IS DISTINCT FROM 'SUBINSTALLATION' OR parent_level IS DISTINCT FROM 'INSTALLATION' THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La compatibilidad debe vincular Subinstalación con Instalación';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_inventory_family_compatibility_rule
|
||||
BEFORE INSERT OR UPDATE OF child_family_id,parent_family_id ON inventory_family_parent_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_inventory_family_compatibility_rule()
|
||||
`);
|
||||
|
||||
// Family-specific technical fields. Generic asset type attributes remain for
|
||||
// data genuinely shared by a whole structural level.
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_family_id uuid NOT NULL REFERENCES inventory_families(id) ON DELETE CASCADE,
|
||||
code varchar(80) NOT NULL,
|
||||
name varchar(160) NOT NULL,
|
||||
data_type asset_attribute_data_type NOT NULL,
|
||||
is_required boolean NOT NULL DEFAULT false,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
unit varchar(40) NULL,
|
||||
options jsonb NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_by uuid NULL,
|
||||
updated_by uuid NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_inventory_family_attribute_code UNIQUE(inventory_family_id,code),
|
||||
CONSTRAINT chk_inventory_family_attribute_code CHECK (code ~ '^[a-z][a-z0-9_]*$'),
|
||||
CONSTRAINT chk_inventory_family_attribute_options CHECK (
|
||||
(data_type='SELECT' AND options IS NOT NULL AND jsonb_typeof(options)='array')
|
||||
OR (data_type<>'SELECT')
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_family_attributes_family
|
||||
ON inventory_family_attribute_definitions(inventory_family_id,is_active,sort_order,name)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values (
|
||||
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
definition_id uuid NOT NULL REFERENCES inventory_family_attribute_definitions(id) ON DELETE CASCADE,
|
||||
value jsonb NOT NULL,
|
||||
updated_by uuid NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(asset_id,definition_id)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_inventory_attribute_values_definition
|
||||
ON asset_inventory_attribute_values(definition_id,asset_id)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_asset_inventory_attribute_family()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE asset_family uuid; definition_family uuid;
|
||||
BEGIN
|
||||
SELECT inventory_family_id INTO asset_family FROM assets WHERE id=NEW.asset_id;
|
||||
SELECT inventory_family_id INTO definition_family
|
||||
FROM inventory_family_attribute_definitions WHERE id=NEW.definition_id AND is_active=true;
|
||||
IF asset_family IS NULL OR definition_family IS NULL OR asset_family<>definition_family THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El campo técnico no pertenece a la clasificación del Inventario';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_asset_inventory_attribute_family
|
||||
BEFORE INSERT OR UPDATE OF asset_id,definition_id ON asset_inventory_attribute_values
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_asset_inventory_attribute_family()
|
||||
`);
|
||||
|
||||
// Enforce classification level and Subinstallation compatibility on every
|
||||
// physical Inventory write, not just through the current WEB/API.
|
||||
// A FIELD_SURVEY+DRAFT row may exist momentarily without family because the
|
||||
// field-discovery transaction creates the provisional record before the F6
|
||||
// structure service assigns the family selected by the inspector. Such a row
|
||||
// remains unusable for findings until classification is present.
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_f6_asset_family_contract()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE child_type text; family_level text; parent_family uuid;
|
||||
BEGIN
|
||||
SELECT lower(code) INTO child_type FROM asset_types WHERE id=NEW.asset_type_id;
|
||||
|
||||
IF child_type IN ('departamento','area','yacimiento') THEN
|
||||
IF NEW.inventory_family_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento, Área y Yacimiento no llevan clasificación técnica';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF child_type='instalacion' THEN
|
||||
IF NEW.inventory_family_id IS NULL THEN
|
||||
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación requiere clasificación técnica';
|
||||
END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Instalación';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF child_type='subinstalacion' THEN
|
||||
IF NEW.parent_id IS NULL THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere una Instalación padre';
|
||||
END IF;
|
||||
IF NEW.inventory_family_id IS NULL THEN
|
||||
IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere clasificación técnica';
|
||||
END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW.parent_id;
|
||||
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Subinstalación';
|
||||
END IF;
|
||||
IF parent_family IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM inventory_family_parent_rules rule
|
||||
WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family
|
||||
) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación de Subinstalación no es compatible con la Instalación padre';
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
`);
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_f6_asset_family_contract
|
||||
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,inventory_family_id ON assets
|
||||
FOR EACH ROW EXECUTE FUNCTION enforce_f6_asset_family_contract()
|
||||
`);
|
||||
|
||||
const [check] = (await queryRunner.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::integer FROM pg_constraint c JOIN pg_class r ON r.oid=c.conrelid
|
||||
WHERE r.relname='inventory_family_parent_rules' AND c.contype='p'
|
||||
AND pg_get_constraintdef(c.oid) LIKE '%child_family_id, parent_family_id%') AS composite_pk,
|
||||
to_regclass('inventory_family_attribute_definitions') IS NOT NULL AS family_attributes,
|
||||
to_regclass('asset_inventory_attribute_values') IS NOT NULL AS family_values
|
||||
`)) as Array<{ composite_pk:number; family_attributes:boolean; family_values:boolean }>;
|
||||
if (!check || Number(check.composite_pk)!==1 || !check.family_attributes || !check.family_values) {
|
||||
throw new Error(`F6 inventory model verification failed: ${JSON.stringify(check ?? {})}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets');
|
||||
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f6_asset_family_contract()');
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values');
|
||||
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_asset_inventory_attribute_family()');
|
||||
await queryRunner.query('DROP TABLE IF EXISTS asset_inventory_attribute_values');
|
||||
await queryRunner.query('DROP TABLE IF EXISTS inventory_family_attribute_definitions');
|
||||
await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules');
|
||||
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_inventory_family_compatibility_rule()');
|
||||
await queryRunner.query('DROP INDEX IF EXISTS idx_inventory_family_parent_rules_parent');
|
||||
|
||||
// Old schema allowed only one Installation family per Subinstallation family.
|
||||
// Keep a deterministic first relation if F6 data must be rolled back.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM inventory_family_parent_rules rule
|
||||
USING inventory_family_parent_rules keep
|
||||
WHERE rule.child_family_id=keep.child_family_id
|
||||
AND rule.parent_family_id>keep.parent_family_id
|
||||
`);
|
||||
await queryRunner.query('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT IF EXISTS pk_inventory_family_parent_rules');
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE inventory_family_parent_rules
|
||||
ADD CONSTRAINT inventory_family_parent_rules_pkey PRIMARY KEY(child_family_id)
|
||||
`);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* F5/F6 permits physical Inventory to belong to an Área without persisting a
|
||||
* Company snapshot. Company snapshots are optional creation/history metadata;
|
||||
* current ownership is resolved from area_company_relations.
|
||||
*
|
||||
* The F4 paired CHECK survived the trigger migration and contradicted that
|
||||
* model. Keep only the invariant that a Company snapshot cannot exist without
|
||||
* an Área.
|
||||
*/
|
||||
export class F61AreaOwnedOperationalContextCheck1790094500000 implements MigrationInterface {
|
||||
name='F61AreaOwnedOperationalContextCheck1790094500000';
|
||||
|
||||
public async up(queryRunner:QueryRunner):Promise<void>{
|
||||
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE assets
|
||||
ADD CONSTRAINT chk_assets_operational_assignment_pair
|
||||
CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner:QueryRunner):Promise<void>{
|
||||
await queryRunner.query('ALTER TABLE assets DROP CONSTRAINT IF EXISTS chk_assets_operational_assignment_pair');
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE assets
|
||||
ADD CONSTRAINT chk_assets_operational_assignment_pair
|
||||
CHECK (
|
||||
(operational_area_id IS NULL AND operator_company_id IS NULL)
|
||||
OR (operational_area_id IS NOT NULL AND operator_company_id IS NOT NULL)
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { F61_PRESENTATION_TERRITORY_SOURCE as SOURCE } from '../../reference-data/f6-1-presentation-territory-source';
|
||||
|
||||
type IdRow={id:string};
|
||||
const NO_OPERATOR='Sin Empresa Operadora';
|
||||
const DOC='DH-F6.1-PRESENTATION-TERRITORY-20260909';
|
||||
|
||||
const key=(value:string)=>value.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' ');
|
||||
const code=(prefix:string,value:string,length=12)=>
|
||||
`${prefix}-${createHash('sha1').update(value).digest('hex').slice(0,length).toUpperCase()}`;
|
||||
const uniqueBy=<T>(rows:T[],identity:(row:T)=>string)=>{
|
||||
const seen=new Set<string>(); return rows.filter((row)=>{const id=identity(row);if(seen.has(id))return false;seen.add(id);return true;});
|
||||
};
|
||||
|
||||
export class F61PresentationTerritoryReset1790094600000 implements MigrationInterface {
|
||||
name='F61PresentationTerritoryReset1790094600000';
|
||||
|
||||
public async up(q:QueryRunner):Promise<void>{
|
||||
const rows=SOURCE.rows;
|
||||
if(SOURCE.file!=='Tablas de yacimiento y areas(1).xlsx'||SOURCE.sheet!=='cr26e_tabla1'
|
||||
||SOURCE.sha256!=='afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb'||rows.length!==230)
|
||||
throw new Error('F6.1 presentation source contract mismatch');
|
||||
|
||||
const areas=uniqueBy(rows,(r)=>key(r.area));
|
||||
const pairs=uniqueBy(rows,(r)=>`${key(r.area)}|${key(r.yacimiento)}`);
|
||||
const departments=uniqueBy(rows,(r)=>key(r.departamento));
|
||||
const companies=[...new Set(rows.map((r)=>r.empresaOperadora.trim())
|
||||
.filter((name)=>name&&key(name)!==key(NO_OPERATOR)))].sort((a,b)=>a.localeCompare(b,'es'));
|
||||
if(areas.length!==64||pairs.length!==230||departments.length!==7||companies.length!==12
|
||||
||areas.filter((r)=>key(r.empresaOperadora)!==key(NO_OPERATOR)).length!==47)
|
||||
throw new Error('F6.1 presentation source cardinality mismatch');
|
||||
|
||||
const concessions=new Set(rows.map((r)=>r.tipoConcesion.trim()));
|
||||
if(concessions.size!==2||!concessions.has('Exploración')||!concessions.has('Explotación'))
|
||||
throw new Error('F6.1 presentation concession contract mismatch');
|
||||
for(const area of areas){
|
||||
const same=rows.filter((r)=>key(r.area)===key(area.area));
|
||||
for(const field of ['departamento','tipoConcesion','empresaOperadora'] as const)
|
||||
if(new Set(same.map((r)=>key(r[field]))).size!==1) throw new Error(`Conflicting ${field}: ${area.area}`);
|
||||
}
|
||||
|
||||
const companyType=await this.type(q,
|
||||
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`);
|
||||
const departmentType=await this.type(q,
|
||||
`SELECT id FROM asset_types WHERE lower(code)='departamento' AND is_active=true AND can_be_root=true LIMIT 1`);
|
||||
const areaType=await this.type(q,
|
||||
`SELECT id FROM asset_types WHERE lower(code)='area' AND operational_role='AREA' AND is_active=true LIMIT 1`);
|
||||
const yacimientoType=await this.type(q,
|
||||
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`);
|
||||
const [rules]=await q.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)='area' AND lower(parent.code)='departamento')
|
||||
OR (lower(child.code)='yacimiento' AND lower(parent.code)='area')`);
|
||||
if(Number(rules?.total)!==2) throw new Error('F6.1 canonical hierarchy rules are incomplete');
|
||||
|
||||
// Destructive clean start requested for the presentation. Users, RBAC,
|
||||
// technical families, Finding masters and system configuration are preserved.
|
||||
// Recovery is the automatic PRE database backup made by deploy-github.sh.
|
||||
await q.query('TRUNCATE TABLE assets CASCADE');
|
||||
await q.query('TRUNCATE TABLE administrative_departments CASCADE');
|
||||
await q.query('TRUNCATE TABLE source_documents CASCADE');
|
||||
await q.query('TRUNCATE TABLE audit_events');
|
||||
await q.query(`DO $$ DECLARE t text; BEGIN FOR t IN SELECT tablename FROM pg_tables
|
||||
WHERE schemaname=current_schema() AND tablename LIKE 'asset_import_%'
|
||||
LOOP EXECUTE format('TRUNCATE TABLE %I CASCADE',t); END LOOP; END $$;`);
|
||||
|
||||
const [doc]=(await q.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`,
|
||||
[DOC,SOURCE.file,`sha256:${SOURCE.sha256}`,`F6.1 · ${SOURCE.sheet} · ${rows.length} filas`])) as IdRow[];
|
||||
if(!doc?.id) throw new Error('Could not create F6.1 presentation source');
|
||||
|
||||
const companyIds=new Map<string,string>();
|
||||
for(const name of companies){
|
||||
const id=await this.asset(q,companyType,null,null,code('PRES-ORG',key(name)),name,
|
||||
'Empresa/Operadora del corte de presentación.',`F6.1:PRESENTATION:COMPANY:${code('SRC',key(name),10)}`,'Empresa');
|
||||
companyIds.set(key(name),id);
|
||||
await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name)
|
||||
VALUES($1::uuid,$2::organization_kind,$3)`,[id,name.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',name]);
|
||||
await this.link(q,id,doc.id,'Empresa/Operadora');
|
||||
}
|
||||
|
||||
const departmentIds=new Map<string,string>();
|
||||
for(const row of departments.sort((a,b)=>a.departamento.localeCompare(b.departamento,'es'))){
|
||||
const id=await this.asset(q,departmentType,null,null,code('PRES-DEP',key(row.departamento)),row.departamento,
|
||||
'Departamento administrativo raíz F6.1.',`F6.1:PRESENTATION:DEPARTMENT:${code('SRC',key(row.departamento),10)}`,'Departamento');
|
||||
departmentIds.set(key(row.departamento),id); await this.link(q,id,doc.id,'Departamento');
|
||||
}
|
||||
|
||||
const areaIds=new Map<string,string>();
|
||||
for(const row of areas.sort((a,b)=>a.area.localeCompare(b.area,'es'))){
|
||||
const parent=departmentIds.get(key(row.departamento)); if(!parent)throw new Error(`Missing Departamento ${row.departamento}`);
|
||||
const id=await this.asset(q,areaType,parent,null,code('PRES-AREA',key(row.area)),row.area,
|
||||
`Área del Departamento ${row.departamento}.`,`F6.1:PRESENTATION:AREA:${code('SRC',key(row.area),10)}`,'Área');
|
||||
areaIds.set(key(row.area),id); await this.link(q,id,doc.id,`Área · ${row.departamento}`);
|
||||
await q.query(`INSERT INTO area_legal_rights(area_id,right_type,name,status,source_document_id,notes)
|
||||
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4::uuid,$5)`,[
|
||||
id,row.tipoConcesion==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION',
|
||||
`${row.tipoConcesion} · ${row.area}`,doc.id,`F6.1 · ${SOURCE.file}`]);
|
||||
if(key(row.empresaOperadora)!==key(NO_OPERATOR)){
|
||||
const company=companyIds.get(key(row.empresaOperadora)); if(!company)throw new Error(`Missing Empresa ${row.empresaOperadora}`);
|
||||
await q.query(`INSERT INTO area_company_relations(area_id,company_id,relation_role,source_document_id,valid_from,start_reason)
|
||||
VALUES($1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4)`,
|
||||
[id,company,doc.id,`F6.1 · operadora vigente según ${SOURCE.file}`]);
|
||||
}
|
||||
}
|
||||
|
||||
for(const row of pairs){
|
||||
const area=areaIds.get(key(row.area)); if(!area)throw new Error(`Missing Área ${row.area}`);
|
||||
const id=await this.asset(q,yacimientoType,area,area,code('PRES-YAC',`${key(row.area)}|${key(row.yacimiento)}`),
|
||||
row.yacimiento,`Yacimiento del Área ${row.area}.`,
|
||||
`F6.1:PRESENTATION:YAC:${code('SRC',`${key(row.area)}|${key(row.yacimiento)}`,12)}`,`${SOURCE.sheet} · fila ${row.sourceRow}`);
|
||||
await this.link(q,id,doc.id,`Yacimiento · ${row.area} · fila ${row.sourceRow}`);
|
||||
}
|
||||
|
||||
const [c]=await q.query(`SELECT
|
||||
(SELECT COUNT(*) FROM assets)::integer total_assets,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE t.operational_role='COMPANY')::integer companies,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='departamento')::integer departments,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area')::integer areas,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='yacimiento')::integer yacimientos,
|
||||
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE')::integer legal_rights,
|
||||
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLORATION_PERMIT')::integer exploration_rights,
|
||||
(SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLOITATION_CONCESSION')::integer exploitation_rights,
|
||||
(SELECT COUNT(*) FROM area_company_relations WHERE relation_role='OPERATOR' AND valid_until IS NULL)::integer operators,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area'
|
||||
AND NOT EXISTS(SELECT 1 FROM area_company_relations r WHERE r.area_id=a.id AND r.relation_role='OPERATOR' AND r.valid_until IS NULL))::integer areas_without_operator,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN assets p ON p.id=a.parent_id
|
||||
LEFT JOIN asset_types pt ON pt.id=p.asset_type_id WHERE lower(t.code)='area' AND (p.id IS NULL OR lower(pt.code)<>'departamento'))::integer invalid_area_parents,
|
||||
(SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id LEFT JOIN assets a ON a.id=y.parent_id
|
||||
LEFT JOIN asset_types at ON at.id=a.asset_type_id WHERE lower(t.code)='yacimiento'
|
||||
AND (a.id IS NULL OR lower(at.code)<>'area' OR y.operational_area_id IS DISTINCT FROM a.id))::integer invalid_yacimiento_contexts,
|
||||
(SELECT COUNT(*) FROM asset_source_documents WHERE document_id=$1::uuid)::integer source_links,
|
||||
(SELECT COUNT(*) FROM source_documents WHERE document_number=$2)::integer source_documents`,[doc.id,DOC]);
|
||||
const expected:Record<string,number>={total_assets:313,companies:12,departments:7,areas:64,yacimientos:230,
|
||||
legal_rights:64,exploration_rights:18,exploitation_rights:46,operators:47,areas_without_operator:17,
|
||||
invalid_area_parents:0,invalid_yacimiento_contexts:0,source_links:313,source_documents:1};
|
||||
for(const [field,value] of Object.entries(expected))
|
||||
if(Number(c?.[field]??-1)!==value)throw new Error(`F6.1 seed verification failed: ${field}=${c?.[field]} expected=${value}`);
|
||||
}
|
||||
|
||||
public async down():Promise<void>{
|
||||
throw new Error('F6.1 presentation reset is destructive. Restore the PRE deploy database backup.');
|
||||
}
|
||||
|
||||
private async type(q:QueryRunner,sql:string):Promise<string>{
|
||||
const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing F6.1 master type: ${sql}`); return rows[0].id;
|
||||
}
|
||||
private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,assetCode:string,name:string,
|
||||
description:string,sourceReference:string,sourceNotes:string):Promise<string>{
|
||||
const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
|
||||
code,name,description,information_status,operational_status,data_origin,source_name,source_reference,source_notes,is_inventory_instance)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,NULL,NULL,$4,$5,$6,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$7,$8,$9,false) RETURNING id`,
|
||||
[typeId,parentId,areaId,assetCode,name,description,SOURCE.file,sourceReference,sourceNotes])) as IdRow[];
|
||||
if(!row?.id)throw new Error(`Could not seed ${name}`); return row.id;
|
||||
}
|
||||
private async link(q:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void>{
|
||||
await q.query(`INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
|
||||
VALUES($1::uuid,$2::uuid,'SOURCE',$3)`,[assetId,documentId,notes]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F62FreezeInspectionContext1790098200000 implements MigrationInterface {
|
||||
name = 'F62FreezeInspectionContext1790098200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE OR REPLACE FUNCTION prevent_inspection_context_mutation()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.operational_area_id IS DISTINCT FROM OLD.operational_area_id
|
||||
OR NEW.scope_asset_id IS DISTINCT FROM OLD.scope_asset_id
|
||||
OR NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
|
||||
RAISE EXCEPTION USING
|
||||
ERRCODE='23514',
|
||||
MESSAGE='El Área, Yacimiento y Operadora de una Inspección quedan fijos desde su creación';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE TRIGGER trg_inspection_context_immutable
|
||||
BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id
|
||||
ON inspection_visits
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_inspection_context_mutation()
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits
|
||||
`);
|
||||
await queryRunner.query('DROP FUNCTION IF EXISTS prevent_inspection_context_mutation()');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class F63MobileFieldCommonAttributes1790099100000 implements MigrationInterface {
|
||||
name = 'F63MobileFieldCommonAttributes1790099100000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
WITH target_types AS (
|
||||
SELECT id
|
||||
FROM asset_types
|
||||
WHERE lower(code) IN ('instalacion','subinstalacion')
|
||||
), fields(code,name,sort_order) AS (
|
||||
VALUES
|
||||
('campo_marca','Marca',10),
|
||||
('campo_modelo','Modelo',20),
|
||||
('campo_capacidad','Capacidad',30),
|
||||
('campo_numero_serie','Número de serie',40),
|
||||
('campo_funcion','Función',50)
|
||||
)
|
||||
INSERT INTO asset_attribute_definitions (
|
||||
asset_type_id, code, name, data_type, is_required, is_active, sort_order
|
||||
)
|
||||
SELECT target.id, fields.code, fields.name, 'TEXT'::asset_attribute_data_type,
|
||||
false, true, fields.sort_order
|
||||
FROM target_types target
|
||||
CROSS JOIN fields
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM asset_attribute_definitions existing
|
||||
WHERE existing.asset_type_id=target.id
|
||||
AND lower(existing.code)=lower(fields.code)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM asset_attribute_values value
|
||||
USING asset_attribute_definitions definition, asset_types type
|
||||
WHERE value.definition_id=definition.id
|
||||
AND definition.asset_type_id=type.id
|
||||
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||
AND definition.code IN (
|
||||
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
DELETE FROM asset_attribute_definitions definition
|
||||
USING asset_types type
|
||||
WHERE definition.asset_type_id=type.id
|
||||
AND lower(type.code) IN ('instalacion','subinstalacion')
|
||||
AND definition.code IN (
|
||||
'campo_marca','campo_modelo','campo_capacidad','campo_numero_serie','campo_funcion'
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
const TARGET_CODES = [
|
||||
'departamento',
|
||||
'area',
|
||||
'yacimiento',
|
||||
'instalacion',
|
||||
'subinstalacion',
|
||||
] as const;
|
||||
|
||||
const DELETE_ORDER = [
|
||||
'subinstalacion',
|
||||
'instalacion',
|
||||
'yacimiento',
|
||||
'area',
|
||||
'departamento',
|
||||
] as const;
|
||||
|
||||
type ProtectedSnapshot = {
|
||||
users: string;
|
||||
companies: string;
|
||||
companyProfiles: string;
|
||||
assetTypes: string;
|
||||
assetAttributes: string;
|
||||
inventoryFamilies: string;
|
||||
familyAttributes: string;
|
||||
findingCategories: string;
|
||||
findingItems: string;
|
||||
};
|
||||
|
||||
export class ResetOperationalHierarchyData1790099200000 implements MigrationInterface {
|
||||
name = 'ResetOperationalHierarchyData1790099200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// This is a one-time live-data cleanup, not a new canonical empty seed.
|
||||
// Fresh CI/bootstrap databases intentionally have no admin account while
|
||||
// replaying the historical migration chain, so they must retain the F6.1
|
||||
// presentation seed used by hierarchy/planning contract tests.
|
||||
const adminRows = (await queryRunner.query(`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE lower(btrim(username))='admin'
|
||||
ORDER BY id
|
||||
`)) as Array<{ id: string }>;
|
||||
if (adminRows.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[hierarchy-reset] skipped: no live admin account on migration replay');
|
||||
return;
|
||||
}
|
||||
if (adminRows.length !== 1) {
|
||||
throw new Error(
|
||||
`Hierarchy reset aborted: expected exactly one live admin account, found ${adminRows.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetTypes = (await queryRunner.query(
|
||||
`
|
||||
SELECT lower(code) AS code
|
||||
FROM asset_types
|
||||
WHERE lower(code)=ANY($1::text[])
|
||||
ORDER BY lower(code)
|
||||
`,
|
||||
[[...TARGET_CODES]],
|
||||
)) as Array<{ code: string }>;
|
||||
|
||||
const found = new Set(targetTypes.map((row) => row.code));
|
||||
const missing = TARGET_CODES.filter((code) => !found.has(code));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Hierarchy reset aborted: missing asset types ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const before = await this.protectedSnapshot(queryRunner);
|
||||
|
||||
await queryRunner.query(
|
||||
`
|
||||
CREATE TEMP TABLE reset_target_assets ON COMMIT DROP AS
|
||||
SELECT asset.id
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE lower(type.code)=ANY($1::text[])
|
||||
`,
|
||||
[[...TARGET_CODES]],
|
||||
);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX reset_target_assets_pk ON reset_target_assets(id)`);
|
||||
|
||||
const [targetCount] = (await queryRunner.query(
|
||||
`SELECT COUNT(*)::integer AS total FROM reset_target_assets`,
|
||||
)) as Array<{ total: number }>;
|
||||
|
||||
// An Inspection freezes Area/Yacimiento/Operadora from creation and several
|
||||
// inspection tables hold RESTRICT references to the hierarchy. Keeping a
|
||||
// transaction that points to deleted territory would be invalid, so the
|
||||
// complete disposable inspection graph is cleared first.
|
||||
await queryRunner.query('TRUNCATE TABLE inspection_visits CASCADE');
|
||||
|
||||
// Legacy administrative departments are also presentation/operational data.
|
||||
// Current F6 Departments live in assets, but this prevents old rows from
|
||||
// resurfacing through compatibility paths.
|
||||
await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE');
|
||||
|
||||
// Legal-right participants depend on area_legal_rights rather than directly
|
||||
// on assets. Remove them before the generic direct-FK cleanup below.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM area_legal_right_organizations organization
|
||||
USING area_legal_rights legal_right
|
||||
WHERE organization.right_id=legal_right.id
|
||||
AND legal_right.area_id IN (SELECT id FROM reset_target_assets)
|
||||
`);
|
||||
|
||||
// Clean every table that directly references one of the hierarchy assets.
|
||||
// This deliberately discovers the current schema instead of maintaining a
|
||||
// fragile hand-written list as new dossier/history tables are added.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE dependency record;
|
||||
BEGIN
|
||||
FOR dependency IN
|
||||
SELECT
|
||||
namespace.nspname AS schema_name,
|
||||
relation.relname AS table_name,
|
||||
attribute.attname AS column_name
|
||||
FROM pg_constraint constraint_row
|
||||
JOIN pg_class relation ON relation.oid=constraint_row.conrelid
|
||||
JOIN pg_namespace namespace ON namespace.oid=relation.relnamespace
|
||||
JOIN LATERAL unnest(constraint_row.conkey) WITH ORDINALITY local_key(attnum,ordinality)
|
||||
ON true
|
||||
JOIN LATERAL unnest(constraint_row.confkey) WITH ORDINALITY referenced_key(attnum,ordinality)
|
||||
ON referenced_key.ordinality=local_key.ordinality
|
||||
JOIN pg_attribute attribute
|
||||
ON attribute.attrelid=constraint_row.conrelid
|
||||
AND attribute.attnum=local_key.attnum
|
||||
JOIN pg_attribute referenced_attribute
|
||||
ON referenced_attribute.attrelid=constraint_row.confrelid
|
||||
AND referenced_attribute.attnum=referenced_key.attnum
|
||||
WHERE constraint_row.contype='f'
|
||||
AND constraint_row.confrelid='assets'::regclass
|
||||
AND constraint_row.conrelid<>'assets'::regclass
|
||||
AND array_length(constraint_row.conkey,1)=1
|
||||
AND referenced_attribute.attname='id'
|
||||
ORDER BY namespace.nspname,relation.relname,attribute.attname
|
||||
LOOP
|
||||
EXECUTE format(
|
||||
'DELETE FROM %I.%I WHERE %I IN (SELECT id FROM reset_target_assets)',
|
||||
dependency.schema_name,
|
||||
dependency.table_name,
|
||||
dependency.column_name
|
||||
);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// parent_id is RESTRICT, therefore physical hierarchy rows are deleted from
|
||||
// the leaves upward. Company/Operator assets are intentionally not targets.
|
||||
for (const code of DELETE_ORDER) {
|
||||
await queryRunner.query(
|
||||
`
|
||||
DELETE FROM assets asset
|
||||
USING asset_types type
|
||||
WHERE asset.asset_type_id=type.id
|
||||
AND lower(type.code)=$1
|
||||
`,
|
||||
[code],
|
||||
);
|
||||
}
|
||||
|
||||
const after = await this.protectedSnapshot(queryRunner);
|
||||
for (const key of Object.keys(before) as Array<keyof ProtectedSnapshot>) {
|
||||
if (before[key] !== after[key]) {
|
||||
throw new Error(
|
||||
`Hierarchy reset verification failed: protected ${key} changed (${before[key]} -> ${after[key]})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [verification] = (await queryRunner.query(`
|
||||
SELECT
|
||||
(
|
||||
SELECT COUNT(*)::integer
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE lower(type.code)=ANY($1::text[])
|
||||
) AS hierarchy_assets,
|
||||
(SELECT COUNT(*)::integer FROM administrative_departments) AS administrative_departments,
|
||||
(SELECT COUNT(*)::integer FROM inspection_visits) AS inspection_visits
|
||||
`, [[...TARGET_CODES]])) as Array<{
|
||||
hierarchy_assets: number;
|
||||
administrative_departments: number;
|
||||
inspection_visits: number;
|
||||
}>;
|
||||
|
||||
if (
|
||||
!verification
|
||||
|| Number(verification.hierarchy_assets) !== 0
|
||||
|| Number(verification.administrative_departments) !== 0
|
||||
|| Number(verification.inspection_visits) !== 0
|
||||
) {
|
||||
throw new Error(`Hierarchy reset verification failed: ${JSON.stringify(verification ?? {})}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[hierarchy-reset] removed ${Number(targetCount?.total ?? 0)} Departamento/Área/Yacimiento/Instalación/Subinstalación assets; inspections and legacy departments cleared; users=${after.users}; companies=${after.companies} preserved`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
throw new Error(
|
||||
'ResetOperationalHierarchyData is intentionally destructive; restore the automatic deploy PRE database backup instead.',
|
||||
);
|
||||
}
|
||||
|
||||
private async protectedSnapshot(queryRunner: QueryRunner): Promise<ProtectedSnapshot> {
|
||||
const [snapshot] = (await queryRunner.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*)::text FROM users) AS "users",
|
||||
(
|
||||
SELECT COUNT(*)::text
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE type.operational_role='COMPANY'
|
||||
) AS "companies",
|
||||
(
|
||||
SELECT COUNT(*)::text
|
||||
FROM organization_profiles profile
|
||||
JOIN assets asset ON asset.id=profile.asset_id
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE type.operational_role='COMPANY'
|
||||
) AS "companyProfiles",
|
||||
(SELECT COUNT(*)::text FROM asset_types) AS "assetTypes",
|
||||
(SELECT COUNT(*)::text FROM asset_attribute_definitions) AS "assetAttributes",
|
||||
(SELECT COUNT(*)::text FROM inventory_families) AS "inventoryFamilies",
|
||||
(SELECT COUNT(*)::text FROM inventory_family_attribute_definitions) AS "familyAttributes",
|
||||
(SELECT COUNT(*)::text FROM finding_categories) AS "findingCategories",
|
||||
(SELECT COUNT(*)::text FROM finding_catalog_items) AS "findingItems"
|
||||
`)) as ProtectedSnapshot[];
|
||||
|
||||
if (!snapshot) throw new Error('Hierarchy reset aborted: could not snapshot protected masters');
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* One-time full live-data reset requested before loading the definitive source files.
|
||||
*
|
||||
* The database is left with only the single `admin` user and the minimum product
|
||||
* scaffolding required to keep authentication/authorization and the core asset
|
||||
* model functional. Every business, operational, imported, catalog, history,
|
||||
* document, inspection and tenant/company row is removed.
|
||||
*
|
||||
* Fresh migration replays/CI do not have the live `admin` account at this point,
|
||||
* so this migration intentionally no-ops there.
|
||||
*/
|
||||
export class FullLiveDataReset1790103000000 implements MigrationInterface {
|
||||
name = 'FullLiveDataReset1790103000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const adminRows = (await queryRunner.query(`
|
||||
SELECT id, username
|
||||
FROM users
|
||||
WHERE lower(btrim(username))='admin'
|
||||
ORDER BY id
|
||||
`)) as Array<{ id: string; username: string }>;
|
||||
|
||||
if (adminRows.length === 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[full-live-reset] skipped: no live admin account on migration replay');
|
||||
return;
|
||||
}
|
||||
|
||||
if (adminRows.length !== 1) {
|
||||
throw new Error(
|
||||
`Full live reset aborted: expected exactly one username admin, found ${adminRows.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const adminId = adminRows[0].id;
|
||||
|
||||
// These are product/schema scaffolding, not customer/business data.
|
||||
// Everything else in public is disposable live data for this reset.
|
||||
const structuralTables = [
|
||||
'roles',
|
||||
'permissions',
|
||||
'role_permissions',
|
||||
'asset_types',
|
||||
'asset_attribute_definitions',
|
||||
'asset_type_parent_rules',
|
||||
] as const;
|
||||
|
||||
const preservedTables = new Set<string>([
|
||||
'typeorm_migrations',
|
||||
'users',
|
||||
'user_roles',
|
||||
...structuralTables,
|
||||
]);
|
||||
|
||||
const structuralCounts = new Map<string, string>();
|
||||
for (const table of structuralTables) {
|
||||
const safeTable = `"${table.replace(/"/g, '""')}"`;
|
||||
const rows = (await queryRunner.query(
|
||||
`SELECT count(*)::text AS total FROM ${safeTable}`,
|
||||
)) as Array<{ total: string }>;
|
||||
structuralCounts.set(table, rows[0]?.total ?? '0');
|
||||
}
|
||||
|
||||
const adminRolesBefore = (await queryRunner.query(
|
||||
`SELECT count(*)::text AS total FROM user_roles WHERE user_id=$1`,
|
||||
[adminId],
|
||||
)) as Array<{ total: string }>;
|
||||
const adminRoleCount = adminRolesBefore[0]?.total ?? '0';
|
||||
if (adminRoleCount === '0') {
|
||||
throw new Error('Full live reset aborted: admin has no assigned role');
|
||||
}
|
||||
|
||||
const tableRows = (await queryRunner.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema='public'
|
||||
AND table_type='BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`)) as Array<{ table_name: string }>;
|
||||
|
||||
const disposableTables = tableRows
|
||||
.map((row) => row.table_name)
|
||||
.filter((table) => !preservedTables.has(table));
|
||||
|
||||
if (disposableTables.length > 0) {
|
||||
const quoted = disposableTables
|
||||
.map((table) => `"${table.replace(/"/g, '""')}"`)
|
||||
.join(', ');
|
||||
await queryRunner.query(`TRUNCATE TABLE ${quoted} RESTART IDENTITY CASCADE`);
|
||||
}
|
||||
|
||||
// Keep only the owner's administrator account. user_roles for other users
|
||||
// are removed through their FK cascade.
|
||||
await queryRunner.query(`DELETE FROM users WHERE id<>$1`, [adminId]);
|
||||
|
||||
// Invalidate any previous login state and unlock the preserved account.
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET failed_login_attempts=0,
|
||||
locked_until=NULL,
|
||||
last_login_at=NULL,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1
|
||||
`,
|
||||
[adminId],
|
||||
);
|
||||
|
||||
const finalUsers = (await queryRunner.query(`
|
||||
SELECT
|
||||
count(*)::text AS total,
|
||||
count(*) FILTER (WHERE lower(btrim(username))='admin')::text AS admins
|
||||
FROM users
|
||||
`)) as Array<{ total: string; admins: string }>;
|
||||
|
||||
if (finalUsers[0]?.total !== '1' || finalUsers[0]?.admins !== '1') {
|
||||
throw new Error('Full live reset verification failed: users table is not admin-only');
|
||||
}
|
||||
|
||||
const finalAdminRoles = (await queryRunner.query(
|
||||
`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE user_id=$1)::text AS total,
|
||||
count(*) FILTER (WHERE user_id<>$1)::text AS foreign_users
|
||||
FROM user_roles
|
||||
`,
|
||||
[adminId],
|
||||
)) as Array<{ total: string; foreign_users: string }>;
|
||||
|
||||
if (
|
||||
finalAdminRoles[0]?.total !== adminRoleCount ||
|
||||
finalAdminRoles[0]?.foreign_users !== '0'
|
||||
) {
|
||||
throw new Error('Full live reset verification failed: admin role assignments changed');
|
||||
}
|
||||
|
||||
for (const table of structuralTables) {
|
||||
const safeTable = `"${table.replace(/"/g, '""')}"`;
|
||||
const rows = (await queryRunner.query(
|
||||
`SELECT count(*)::text AS total FROM ${safeTable}`,
|
||||
)) as Array<{ total: string }>;
|
||||
const before = structuralCounts.get(table) ?? '0';
|
||||
if (rows[0]?.total !== before) {
|
||||
throw new Error(
|
||||
`Full live reset verification failed: structural table ${table} changed (${before} -> ${rows[0]?.total ?? 'unknown'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const table of disposableTables) {
|
||||
const safeTable = `"${table.replace(/"/g, '""')}"`;
|
||||
const rows = (await queryRunner.query(
|
||||
`SELECT count(*)::text AS total FROM ${safeTable}`,
|
||||
)) as Array<{ total: string }>;
|
||||
if (rows[0]?.total !== '0') {
|
||||
throw new Error(`Full live reset verification failed: ${table} is not empty`);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[full-live-reset] kept admin=${adminRows[0].username} (${adminId}); preserved ${structuralTables.length} product tables; cleared ${disposableTables.length} data tables`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
throw new Error(
|
||||
'FullLiveDataReset is intentionally destructive; restore the automatic deploy PRE database backup instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { AUTHORITATIVE_INVENTORY_MODEL as SOURCE } from '../../reference-data/authoritative-inventory-model';
|
||||
|
||||
type IdRow = { id: string };
|
||||
|
||||
const TERRITORY_DOC = 'DH-AUTH-TERRITORY-20260911';
|
||||
const INVENTORY_DOC = 'DH-AUTH-INVENTORY-20260911';
|
||||
const FINDING_CATEGORY_CODE = 'AUTHMODEL';
|
||||
|
||||
function sourceCode(prefix: string, id: number): string {
|
||||
return `${prefix}-${String(id).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
export class AuthoritativeInventoryRelationships1790106600000 implements MigrationInterface {
|
||||
name = 'AuthoritativeInventoryRelationships1790106600000';
|
||||
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
this.assertSource();
|
||||
|
||||
const companyType = await this.type(q, `SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC, created_at LIMIT 1`);
|
||||
const departmentType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='departamento' AND is_active=true LIMIT 1`);
|
||||
const areaType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='area' AND operational_role='AREA' AND is_active=true LIMIT 1`);
|
||||
const fieldType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`);
|
||||
const installationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='instalacion' AND is_active=true LIMIT 1`);
|
||||
const subinstallationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='subinstalacion' AND is_active=true LIMIT 1`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS concession_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code varchar(80) NOT NULL UNIQUE,
|
||||
name varchar(120) NOT NULL UNIQUE,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_concession_types_name CHECK (length(btrim(name)) >= 3)
|
||||
)
|
||||
`);
|
||||
await q.query(`ALTER TABLE assets ADD COLUMN IF NOT EXISTS concession_type_id uuid`);
|
||||
await q.query(`
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='fk_assets_concession_type') THEN
|
||||
ALTER TABLE assets ADD CONSTRAINT fk_assets_concession_type
|
||||
FOREIGN KEY (concession_type_id) REFERENCES concession_types(id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
END $$
|
||||
`);
|
||||
await q.query(`CREATE INDEX IF NOT EXISTS idx_assets_concession_type_id ON assets(concession_type_id)`);
|
||||
await q.query(`
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN
|
||||
GRANT SELECT ON concession_types TO dhv2_app;
|
||||
END IF;
|
||||
END $$
|
||||
`);
|
||||
|
||||
await q.query(`DELETE FROM asset_type_parent_rules WHERE child_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]);
|
||||
await q.query(`
|
||||
INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id) VALUES
|
||||
($1::uuid,$2::uuid),($3::uuid,$1::uuid),($4::uuid,$3::uuid),($5::uuid,$4::uuid)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [areaType, departmentType, fieldType, installationType, subinstallationType]);
|
||||
await q.query(`UPDATE asset_types SET can_be_root=true,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid)`, [companyType, departmentType]);
|
||||
await q.query(`UPDATE asset_types SET can_be_root=false,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]);
|
||||
await q.query(`UPDATE asset_types SET description='Área territorial. Sólo posee Nombre y pertenece obligatoriamente a un Departamento.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [areaType]);
|
||||
await q.query(`UPDATE asset_types SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [fieldType]);
|
||||
await q.query(`UPDATE asset_types SET description='Instalación física. Pertenece obligatoriamente a un Yacimiento y utiliza una clasificación técnica.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [installationType]);
|
||||
await q.query(`UPDATE asset_types SET description='Subinstalación física. Pertenece obligatoriamente a una Instalación y utiliza una clasificación técnica compatible.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [subinstallationType]);
|
||||
|
||||
await q.query(`DELETE FROM asset_attribute_definitions WHERE asset_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid)`, [companyType, departmentType, areaType, fieldType, installationType, subinstallationType]);
|
||||
await this.insertCommonAttributes(q, installationType, [
|
||||
['campo_marca','Marca',10],
|
||||
['tipo_instalacion','Tipo de instalación',20],
|
||||
['campo_modelo','Modelo',30],
|
||||
['campo_capacidad','Capacidad',40],
|
||||
['campo_numero_serie','Número de serie',50],
|
||||
['campo_funcion','Función',60],
|
||||
]);
|
||||
await this.insertCommonAttributes(q, subinstallationType, [
|
||||
['campo_marca','Marca',10],
|
||||
['campo_modelo','Modelo',20],
|
||||
['campo_capacidad','Capacidad',30],
|
||||
['campo_numero_serie','Número de serie',40],
|
||||
['campo_funcion','Función',50],
|
||||
]);
|
||||
|
||||
await q.query(`
|
||||
DO $$ DECLARE trigger_name text; BEGIN
|
||||
FOR trigger_name IN
|
||||
SELECT trigger_row.tgname
|
||||
FROM pg_trigger trigger_row
|
||||
JOIN pg_proc function_row ON function_row.oid=trigger_row.tgfoid
|
||||
WHERE trigger_row.tgrelid='assets'::regclass
|
||||
AND NOT trigger_row.tgisinternal
|
||||
AND function_row.proname='enforce_asset_operational_context'
|
||||
LOOP
|
||||
EXECUTE format('DROP TRIGGER %I ON assets',trigger_name);
|
||||
END LOOP;
|
||||
END $$
|
||||
`);
|
||||
await q.query(`DROP FUNCTION IF EXISTS enforce_asset_operational_context()`);
|
||||
|
||||
await q.query(`
|
||||
CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
kind text;
|
||||
asset_role asset_type_operational_role;
|
||||
parent_kind text;
|
||||
parent_area uuid;
|
||||
parent_company uuid;
|
||||
parent_family uuid;
|
||||
company_role asset_type_operational_role;
|
||||
family_level text;
|
||||
BEGIN
|
||||
SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id;
|
||||
IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF;
|
||||
|
||||
IF asset_role='COMPANY'::asset_type_operational_role THEN
|
||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF;
|
||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
RETURN NEW;
|
||||
ELSIF kind='departamento' THEN
|
||||
IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF;
|
||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF;
|
||||
SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id
|
||||
INTO parent_kind,parent_area,parent_company,parent_family
|
||||
FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id
|
||||
WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE';
|
||||
IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF;
|
||||
|
||||
IF kind='area' THEN
|
||||
IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF;
|
||||
NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL;
|
||||
NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
ELSIF kind='yacimiento' THEN
|
||||
IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF;
|
||||
IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF;
|
||||
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='La Empresa relacionada del Yacimiento no es válida'; END IF;
|
||||
IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido';
|
||||
END IF;
|
||||
IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF;
|
||||
NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false;
|
||||
ELSIF kind='instalacion' THEN
|
||||
IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF;
|
||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
||||
ELSIF kind='subinstalacion' THEN
|
||||
IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF;
|
||||
SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true;
|
||||
IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF;
|
||||
IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN
|
||||
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre';
|
||||
END IF;
|
||||
NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$
|
||||
`);
|
||||
await q.query(`DROP TRIGGER IF EXISTS trg_assets_authoritative_inventory_relationships ON assets`);
|
||||
await q.query(`
|
||||
CREATE TRIGGER trg_assets_authoritative_inventory_relationships
|
||||
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,concession_type_id
|
||||
ON assets FOR EACH ROW EXECUTE FUNCTION enforce_authoritative_inventory_relationships()
|
||||
`);
|
||||
|
||||
await q.query(`TRUNCATE TABLE assets CASCADE`);
|
||||
await q.query(`TRUNCATE TABLE source_documents CASCADE`);
|
||||
await q.query(`TRUNCATE TABLE inventory_families CASCADE`);
|
||||
await q.query(`TRUNCATE TABLE finding_categories CASCADE`);
|
||||
await q.query(`TRUNCATE TABLE concession_types CASCADE`);
|
||||
|
||||
const [territoryDoc] = (await q.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_DOC,'modelo_yacimientos.sql','sha256:6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a','Modelo relacional exacto de Departamentos, Áreas, Yacimientos, Empresas y Tipo de concesión.'])) as IdRow[];
|
||||
const [inventoryDoc] = (await q.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
|
||||
`, [INVENTORY_DOC,'modelo_relacional instalaciones y sub.sql','sha256:fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49','Modelo relacional exacto de tipos de Instalación/Subinstalación y sus Hallazgos.'])) as IdRow[];
|
||||
if(!territoryDoc?.id || !inventoryDoc?.id) throw new Error('Authoritative source documents could not be created');
|
||||
|
||||
const concessionIds=new Map<number,string>();
|
||||
for(const row of SOURCE.concessionTypes){
|
||||
const [created]=(await q.query(`INSERT INTO concession_types(code,name,is_active) VALUES($1,$2,true) RETURNING id`,[sourceCode('CONC',row.id),row.nombre])) as IdRow[];
|
||||
if(!created?.id)throw new Error(`Could not create concession ${row.nombre}`); concessionIds.set(row.id,created.id);
|
||||
}
|
||||
|
||||
await this.seedFamiliesAndFindings(q);
|
||||
|
||||
const companyIds=new Map<number,string>();
|
||||
for(const row of SOURCE.companies){
|
||||
const id=await this.asset(q,companyType,null,null,null,null,sourceCode('ORG',row.id),row.nombre,'modelo_yacimientos.sql',`empresas:${row.id}`);
|
||||
companyIds.set(row.id,id);
|
||||
await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name) VALUES($1::uuid,$2::organization_kind,$3)`,[id,row.nombre.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',row.nombre]);
|
||||
await this.link(q,id,territoryDoc.id,`Empresa · source id ${row.id}`);
|
||||
}
|
||||
const departmentIds=new Map<number,string>();
|
||||
for(const row of SOURCE.departments){
|
||||
const id=await this.asset(q,departmentType,null,null,null,null,sourceCode('DEP',row.id),row.nombre,'modelo_yacimientos.sql',`departamentos:${row.id}`);
|
||||
departmentIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Departamento · source id ${row.id}`);
|
||||
}
|
||||
const areaIds=new Map<number,string>();
|
||||
for(const row of SOURCE.areas){
|
||||
const parent=departmentIds.get(row.departamento_id); if(!parent)throw new Error(`Missing source Departamento ${row.departamento_id}`);
|
||||
const id=await this.asset(q,areaType,parent,null,null,null,sourceCode('AREA',row.id),row.nombre,'modelo_yacimientos.sql',`areas:${row.id}`);
|
||||
areaIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Área · source id ${row.id}`);
|
||||
}
|
||||
for(const row of SOURCE.fields){
|
||||
const area=areaIds.get(row.area_id); const company=companyIds.get(row.empresa_id); const concession=concessionIds.get(row.tipo_concesion_id);
|
||||
if(!area||!company||!concession)throw new Error(`Incomplete source relations for Yacimiento ${row.id}`);
|
||||
const id=await this.asset(q,fieldType,area,area,company,concession,sourceCode('YAC',row.id),row.nombre,'modelo_yacimientos.sql',`yacimientos:${row.id}`);
|
||||
await this.link(q,id,territoryDoc.id,`Yacimiento · source id ${row.id}`);
|
||||
}
|
||||
|
||||
await q.query(`TRUNCATE TABLE area_company_relations CASCADE`);
|
||||
await q.query(`TRUNCATE TABLE area_legal_rights CASCADE`);
|
||||
const projectedPairs=new Set<string>();
|
||||
const projectedRights=new Set<string>();
|
||||
for(const row of SOURCE.fields){
|
||||
const area=areaIds.get(row.area_id)!; const company=companyIds.get(row.empresa_id)!; const concession=concessionIds.get(row.tipo_concesion_id)!;
|
||||
const pair=`${area}|${company}`;
|
||||
if(!projectedPairs.has(pair)){
|
||||
projectedPairs.add(pair);
|
||||
await q.query(`INSERT INTO area_company_relations(area_id,company_id,relation_role,source_document_id,valid_from,start_reason)
|
||||
VALUES($1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4)`,[area,company,territoryDoc.id,'Derivado automáticamente de Yacimientos del modelo autoritativo']);
|
||||
}
|
||||
const rightKey=`${area}|${concession}`;
|
||||
if(!projectedRights.has(rightKey)){
|
||||
projectedRights.add(rightKey);
|
||||
const concessionRow=SOURCE.concessionTypes.find((item)=>item.id===row.tipo_concesion_id)!;
|
||||
await q.query(`INSERT INTO area_legal_rights(area_id,right_type,name,status,source_document_id,notes)
|
||||
VALUES($1::uuid,$2::area_legal_right_type,$3,'ACTIVE',$4::uuid,$5)`,[
|
||||
area,concessionRow.nombre==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION',
|
||||
`${concessionRow.nombre} · proyección del Yacimiento`,territoryDoc.id,'Compatibilidad derivada; el Tipo de concesión canónico está en Yacimiento.']);
|
||||
}
|
||||
}
|
||||
|
||||
await this.installFamilyFindingSync(q);
|
||||
await this.verify(q);
|
||||
}
|
||||
|
||||
public async down():Promise<void>{
|
||||
throw new Error('Authoritative inventory model is a deliberate clean-load migration; restore the deploy PRE database backup.');
|
||||
}
|
||||
|
||||
private assertSource():void{
|
||||
if(SOURCE.departments.length!==7||SOURCE.companies.length!==13||SOURCE.concessionTypes.length!==2||SOURCE.areas.length!==64||SOURCE.fields.length!==230)
|
||||
throw new Error('Authoritative territory source cardinality mismatch');
|
||||
if(SOURCE.installationFamilies.length!==14||SOURCE.subinstallationFamilies.length!==109||SOURCE.findings.length!==181||SOURCE.installationFindings.length!==48||SOURCE.subinstallationFindings.length!==880)
|
||||
throw new Error('Authoritative inventory source cardinality mismatch');
|
||||
for(const area of SOURCE.areas) if(!SOURCE.departments.some((d)=>d.id===area.departamento_id)) throw new Error(`Area ${area.id} has no source Departamento`);
|
||||
for(const field of SOURCE.fields){
|
||||
if(!SOURCE.areas.some((a)=>a.id===field.area_id)||!SOURCE.companies.some((c)=>c.id===field.empresa_id)||!SOURCE.concessionTypes.some((c)=>c.id===field.tipo_concesion_id))
|
||||
throw new Error(`Yacimiento ${field.id} has invalid source relationship`);
|
||||
}
|
||||
}
|
||||
|
||||
private async insertCommonAttributes(q:QueryRunner,typeId:string,rows:Array<[string,string,number]>):Promise<void>{
|
||||
for(const [code,name,sortOrder] of rows) await q.query(`
|
||||
INSERT INTO asset_attribute_definitions(asset_type_id,code,name,data_type,is_required,is_active,unit,options,sort_order)
|
||||
VALUES($1::uuid,$2,$3,'TEXT'::asset_attribute_data_type,false,true,NULL,NULL,$4)
|
||||
`,[typeId,code,name,sortOrder]);
|
||||
}
|
||||
|
||||
private async seedFamiliesAndFindings(q:QueryRunner):Promise<void>{
|
||||
const installationIds=new Map<number,string>();
|
||||
for(const row of SOURCE.installationFamilies){
|
||||
const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active)
|
||||
VALUES($1,$2,'INSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-I',row.id),row.nombre,`modelo_relacional:instalaciones:${row.id}`])) as IdRow[];
|
||||
if(!created?.id)throw new Error(`Could not create Instalación family ${row.id}`); installationIds.set(row.id,created.id);
|
||||
}
|
||||
const subIds=new Map<number,string>();
|
||||
for(const row of SOURCE.subinstallationFamilies){
|
||||
const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active)
|
||||
VALUES($1,$2,'SUBINSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-S',row.id),row.nombre,`modelo_relacional:subinstalaciones:${row.id}`])) as IdRow[];
|
||||
const parent=installationIds.get(row.instalacion_id); if(!created?.id||!parent)throw new Error(`Invalid Subinstalación family ${row.id}`);
|
||||
subIds.set(row.id,created.id);
|
||||
await q.query(`INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) VALUES($1::uuid,$2::uuid)`,[created.id,parent]);
|
||||
}
|
||||
|
||||
const [category]=(await q.query(`INSERT INTO finding_categories(code,name,sort_order,is_active) VALUES($1,$2,300,true) RETURNING id`,[FINDING_CATEGORY_CODE,'DH · Hallazgos del modelo autoritativo'])) as IdRow[];
|
||||
if(!category?.id)throw new Error('Could not create authoritative finding category');
|
||||
const findingIds=new Map<number,string>();
|
||||
for(const row of SOURCE.findings){
|
||||
const [created]=(await q.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`,[category.id,sourceCode('AUTH-H',row.id),row.id,row.nombre,'modelo_relacional instalaciones y sub.sql'])) as IdRow[];
|
||||
if(!created?.id)throw new Error(`Could not create Hallazgo ${row.id}`); findingIds.set(row.id,created.id);
|
||||
await q.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',item.category_id,'code',item.code,'sourceNumber',item.source_number,'title',item.title,'revision',item.revision,'isActive',item.is_active),'migration:AUTHORITATIVE'
|
||||
FROM finding_catalog_items item WHERE item.id=$1::uuid`,[created.id]);
|
||||
}
|
||||
for(const mapping of SOURCE.installationFindings){
|
||||
const family=installationIds.get(mapping.instalacion_id); const finding=findingIds.get(mapping.hallazgo_id);
|
||||
if(!family||!finding)throw new Error('Invalid installation finding mapping');
|
||||
await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]);
|
||||
}
|
||||
for(const mapping of SOURCE.subinstallationFindings){
|
||||
const family=subIds.get(mapping.subinstalacion_id); const finding=findingIds.get(mapping.hallazgo_id);
|
||||
if(!family||!finding)throw new Error('Invalid subinstallation finding mapping');
|
||||
await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]);
|
||||
}
|
||||
}
|
||||
|
||||
private async installFamilyFindingSync(q:QueryRunner):Promise<void>{
|
||||
await q.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 'AUTHORITATIVE 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,m.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',NEW.created_by,NEW.updated_by
|
||||
FROM finding_catalog_item_inventory_families m WHERE m.inventory_family_id=NEW.inventory_family_id
|
||||
ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$
|
||||
`);
|
||||
await q.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 o USING assets a
|
||||
WHERE o.asset_id=a.id AND a.inventory_family_id=OLD.inventory_family_id AND o.catalog_item_id=OLD.catalog_item_id AND o.reason LIKE 'AUTHORITATIVE 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 a.id,NEW.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',a.created_by,a.updated_by
|
||||
FROM assets a WHERE a.inventory_family_id=NEW.inventory_family_id
|
||||
ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END $$
|
||||
`);
|
||||
}
|
||||
|
||||
private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,companyId:string|null,concessionId:string|null,
|
||||
code:string,name:string,sourceName:string,sourceReference:string):Promise<string>{
|
||||
const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
|
||||
code,name,information_status,operational_status,data_origin,source_name,source_reference,is_inventory_instance)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,NULL,$6,$7,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$8,$9,false) RETURNING id`,
|
||||
[typeId,parentId,areaId,companyId,concessionId,code,name,sourceName,sourceReference])) as IdRow[];
|
||||
if(!row?.id)throw new Error(`Could not seed ${code}`); return row.id;
|
||||
}
|
||||
|
||||
private async link(q:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void>{
|
||||
await q.query(`INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes) VALUES($1::uuid,$2::uuid,'SOURCE',$3)`,[assetId,documentId,notes]);
|
||||
}
|
||||
|
||||
private async type(q:QueryRunner,sql:string):Promise<string>{
|
||||
const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing canonical asset type: ${sql}`); return rows[0].id;
|
||||
}
|
||||
|
||||
private async verify(q:QueryRunner):Promise<void>{
|
||||
const [row]=await q.query(`SELECT
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE t.operational_role='COMPANY')::integer companies,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='departamento')::integer departments,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area')::integer areas,
|
||||
(SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='yacimiento')::integer fields,
|
||||
(SELECT COUNT(*) FROM concession_types)::integer concessions,
|
||||
(SELECT COUNT(*) FROM inventory_families WHERE level='INSTALLATION')::integer installation_families,
|
||||
(SELECT COUNT(*) FROM inventory_families WHERE level='SUBINSTALLATION')::integer subinstallation_families,
|
||||
(SELECT COUNT(*) FROM finding_catalog_items)::integer findings,
|
||||
(SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='INSTALLATION')::integer installation_mappings,
|
||||
(SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='SUBINSTALLATION')::integer subinstallation_mappings,
|
||||
(SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id JOIN assets a ON a.id=y.parent_id WHERE lower(t.code)='yacimiento' AND (y.operational_area_id IS DISTINCT FROM a.id OR y.operator_company_id IS NULL OR y.concession_type_id IS NULL))::integer invalid_fields`);
|
||||
const expected:Record<string,number>={companies:13,departments:7,areas:64,fields:230,concessions:2,installation_families:14,subinstallation_families:109,findings:181,installation_mappings:48,subinstallation_mappings:880,invalid_fields:0};
|
||||
for(const [key,value] of Object.entries(expected)) if(Number(row?.[key]??-1)!==value)throw new Error(`Authoritative inventory verification failed: ${key}=${row?.[key]} expected=${value}`);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
@@ -42,7 +41,6 @@ export class CreateInspectionActDto {
|
||||
observations?: string | null;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(200)
|
||||
@ArrayUnique()
|
||||
@IsUUID('4', { each: true })
|
||||
|
||||
@@ -5,10 +5,16 @@ import {
|
||||
InspectionVisitActsController,
|
||||
} from './inspection-acts.controller';
|
||||
import { InspectionActsService } from './inspection-acts.service';
|
||||
import { MobileInspectionActsController } from './mobile-inspection-acts.controller';
|
||||
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule],
|
||||
controllers: [InspectionVisitActsController, InspectionActsController],
|
||||
providers: [InspectionActsService],
|
||||
controllers: [
|
||||
InspectionVisitActsController,
|
||||
MobileInspectionActsController,
|
||||
InspectionActsController,
|
||||
],
|
||||
providers: [InspectionActsService, MobileInspectionActsService],
|
||||
})
|
||||
export class InspectionActsModule {}
|
||||
|
||||
@@ -154,41 +154,26 @@ export class InspectionActsService {
|
||||
SELECT 1
|
||||
FROM inspection_act_assets search_link
|
||||
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
|
||||
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
|
||||
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
|
||||
WHERE search_link.act_id = act.id
|
||||
AND search_link.included = true
|
||||
AND (
|
||||
search_asset.code ILIKE ${search}
|
||||
OR search_asset.name ILIKE ${search}
|
||||
OR search_company.name ILIKE ${search}
|
||||
OR search_area.name ILIKE ${search}
|
||||
)
|
||||
AND (search_asset.code ILIKE ${search} OR search_asset.name ILIKE ${search})
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM assets search_company
|
||||
WHERE search_company.id=visit.operator_company_id
|
||||
AND search_company.name ILIKE ${search}
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM assets search_area
|
||||
WHERE search_area.id=visit.operational_area_id
|
||||
AND search_area.name ILIKE ${search}
|
||||
)
|
||||
)`);
|
||||
}
|
||||
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
|
||||
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
|
||||
if (query.companyId) {
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_act_assets company_link
|
||||
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
|
||||
WHERE company_link.act_id = act.id
|
||||
AND company_link.included = true
|
||||
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
|
||||
)`);
|
||||
}
|
||||
if (query.areaId) {
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_act_assets area_link
|
||||
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
|
||||
WHERE area_link.act_id = act.id
|
||||
AND area_link.included = true
|
||||
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
|
||||
)`);
|
||||
}
|
||||
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
|
||||
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
|
||||
if (query.inspectorId) {
|
||||
const inspector = add(query.inspectorId);
|
||||
conditions.push(`(
|
||||
@@ -245,24 +230,8 @@ export class InspectionActsService {
|
||||
}
|
||||
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
|
||||
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
|
||||
if (query.companyId) {
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM inspection_act_assets company_link
|
||||
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
|
||||
WHERE company_link.act_id = act.id
|
||||
AND company_link.included = true
|
||||
AND company_asset.operator_company_id = ${add(query.companyId)}::uuid
|
||||
)`);
|
||||
}
|
||||
if (query.areaId) {
|
||||
conditions.push(`EXISTS (
|
||||
SELECT 1 FROM inspection_act_assets area_link
|
||||
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
|
||||
WHERE area_link.act_id = act.id
|
||||
AND area_link.included = true
|
||||
AND area_asset.operational_area_id = ${add(query.areaId)}::uuid
|
||||
)`);
|
||||
}
|
||||
if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`);
|
||||
if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`);
|
||||
if (query.inspectorId) {
|
||||
const inspector = add(query.inspectorId);
|
||||
conditions.push(`(
|
||||
@@ -548,21 +517,16 @@ export class InspectionActsService {
|
||||
LEFT JOIN inspection_reports report ON report.act_id = act.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
||||
'id', company.id,
|
||||
'code', company.code,
|
||||
'name', company.name
|
||||
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
|
||||
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
||||
'id', area.id,
|
||||
'code', area.code,
|
||||
'name', area.name
|
||||
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
|
||||
FROM inspection_act_assets context_link
|
||||
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
|
||||
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
|
||||
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
|
||||
WHERE context_link.act_id = act.id AND context_link.included = true
|
||||
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
||||
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
|
||||
) END AS companies,
|
||||
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
||||
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
|
||||
) END AS areas
|
||||
FROM inspection_visits context_visit
|
||||
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
|
||||
WHERE context_visit.id=act.visit_id
|
||||
) context ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS total
|
||||
@@ -692,6 +656,7 @@ export class InspectionActsService {
|
||||
visitId: string,
|
||||
assetIds: string[],
|
||||
): Promise<void> {
|
||||
if (assetIds.length === 0) return;
|
||||
const [row] = (await manager.query(`
|
||||
SELECT COUNT(*)::integer AS count
|
||||
FROM inspection_visit_assets
|
||||
@@ -699,7 +664,7 @@ export class InspectionActsService {
|
||||
AND asset_id = ANY($2::uuid[])
|
||||
AND included = true
|
||||
`, [visitId, assetIds])) as Array<{ count: number }>;
|
||||
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
|
||||
if (Number(row?.count ?? 0) !== assetIds.length) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_ACT_ASSET_INVALID',
|
||||
message: 'Cada inventario del acta debe estar incluido en la inspección',
|
||||
@@ -813,6 +778,8 @@ export class InspectionActsService {
|
||||
'code', visit.code,
|
||||
'status', visit.status,
|
||||
'scopeAssetId', visit.scope_asset_id,
|
||||
'operationalAreaId', visit.operational_area_id,
|
||||
'operatorCompanyId', visit.operator_company_id,
|
||||
'actualStartedAt', visit.actual_started_at
|
||||
),
|
||||
'assets', COALESCE((
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
|
||||
import { MobileInspectionActsService } from './mobile-inspection-acts.service';
|
||||
|
||||
@Controller('inspection-visits/:visitId/acts/mobile')
|
||||
export class MobileInspectionActsController {
|
||||
constructor(private readonly acts: MobileInspectionActsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('inspection_acts.read')
|
||||
list(
|
||||
@Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string,
|
||||
@Query() query: ListInspectionActsQueryDto,
|
||||
) {
|
||||
return this.acts.listForVisit(visitId, query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto';
|
||||
|
||||
/**
|
||||
* Read model intentionally kept small for the field client.
|
||||
*
|
||||
* The Android app only needs the Act identity/lifecycle, deadlines and counters to
|
||||
* open an Inspection. It must not fail because an office-document/report module is
|
||||
* unavailable or temporarily schema-drifted. The canonical office endpoints keep
|
||||
* their richer projection in InspectionActsService.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MobileInspectionActsService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async listForVisit(visitId: string, query: ListInspectionActsQueryDto) {
|
||||
const [visit] = (await this.dataSource.query(
|
||||
'SELECT id FROM inspection_visits WHERE id = $1::uuid',
|
||||
[visitId],
|
||||
)) as Array<{ id: string }>;
|
||||
if (!visit) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_VISIT_NOT_FOUND',
|
||||
message: 'Visita de inspección no encontrada',
|
||||
});
|
||||
}
|
||||
|
||||
const conditions = ['act.visit_id = $1::uuid'];
|
||||
const parameters: unknown[] = [visitId];
|
||||
const add = (value: unknown): string => {
|
||||
parameters.push(value);
|
||||
return `$${parameters.length}`;
|
||||
};
|
||||
|
||||
if (query.search?.trim()) {
|
||||
const search = add(`%${query.search.trim()}%`);
|
||||
conditions.push(`(act.code ILIKE ${search} OR act.title ILIKE ${search})`);
|
||||
}
|
||||
if (query.status) conditions.push(`act.status = ${add(query.status)}`);
|
||||
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
|
||||
if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
|
||||
if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
|
||||
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const [countRow] = (await this.dataSource.query(
|
||||
`SELECT COUNT(*)::integer AS total FROM inspection_acts act ${where}`,
|
||||
parameters,
|
||||
)) as Array<{ total: number }>;
|
||||
const total = Number(countRow?.total ?? 0);
|
||||
|
||||
const limit = add(query.pageSize);
|
||||
const offset = add((query.page - 1) * query.pageSize);
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT
|
||||
act.id,
|
||||
act.visit_id AS "visitId",
|
||||
act.code,
|
||||
act.status,
|
||||
act.occurred_at AS "occurredAt",
|
||||
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_sha256 AS "lockedSha256",
|
||||
act.sealed_at AS "sealedAt",
|
||||
act.current_version AS "currentVersion",
|
||||
act.closed_at AS "closedAt",
|
||||
act.closure_sha256 AS "closureSha256",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM inspection_act_assets link
|
||||
WHERE link.act_id = act.id AND link.included = true
|
||||
), 0)::integer AS "assetCount",
|
||||
COALESCE((
|
||||
SELECT COUNT(*)::integer
|
||||
FROM inspection_findings finding
|
||||
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
|
||||
), 0)::integer AS "findingCount"
|
||||
FROM inspection_acts act
|
||||
${where}
|
||||
ORDER BY act.act_year DESC, act.act_number DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`, parameters);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
total,
|
||||
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,30 +7,66 @@ import { FindingCatalogService } from './finding-catalog.service';
|
||||
export class F3FindingCatalogResolverService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly legacyCatalog: FindingCatalogService,
|
||||
private readonly contextualCatalog: FindingCatalogService,
|
||||
) {}
|
||||
|
||||
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 entre los asociados a este Inventario. Quedará registrado para revisión en oficina.',
|
||||
};
|
||||
|
||||
if (asset.typeCode.toLowerCase() === 'yacimiento') {
|
||||
const contextual = await this.contextualCatalog.listApplicableForAsset(assetId, query);
|
||||
return {
|
||||
...contextual,
|
||||
inventoryFamily: null,
|
||||
catalogSource: 'ASSET_TYPE' as const,
|
||||
configurationReason: contextual.configurationReason
|
||||
?? 'Hallazgos asociados al tipo Yacimiento. OTROS permanece siempre disponible.',
|
||||
other,
|
||||
};
|
||||
}
|
||||
|
||||
if (!asset.familyId || asset.familyActive !== true) {
|
||||
return {
|
||||
asset: { id: asset.id, code: asset.code, name: asset.name },
|
||||
inventoryFamily: null,
|
||||
catalogSource: 'INVENTORY_FAMILY' as const,
|
||||
typeConfigured: false,
|
||||
configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.',
|
||||
categories: [],
|
||||
items: [],
|
||||
other,
|
||||
};
|
||||
}
|
||||
|
||||
const filters = [
|
||||
'mapping.inventory_family_id=$1::uuid',
|
||||
@@ -82,16 +118,12 @@ export class F3FindingCatalogResolverService {
|
||||
name: asset.familyName,
|
||||
level: asset.familyLevel,
|
||||
},
|
||||
catalogSource: 'INVENTORY_FAMILY' as const,
|
||||
typeConfigured: true,
|
||||
configurationReason: `F3.1 · Catálogo del Excel asociado a ${asset.familyName ?? 'la familia técnica'}`,
|
||||
configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(),
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,13 +153,16 @@ export class InspectionFindingWorklistService {
|
||||
workflowFilters.push(`finding.status = 'OPEN'`);
|
||||
}
|
||||
|
||||
// Hallazgo, Acta e Inspección forman un hecho histórico. Empresa y Área se
|
||||
// leen del contexto congelado de la Inspección que originó el Hallazgo, no
|
||||
// del snapshot de compatibilidad del Inventario.
|
||||
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
|
||||
LEFT JOIN assets company ON company.id = visit.operator_company_id
|
||||
LEFT JOIN assets area ON area.id = visit.operational_area_id
|
||||
`;
|
||||
const filters = [...contextFilters, ...workflowFilters];
|
||||
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
|
||||
@@ -408,30 +408,24 @@ export class InspectionReportsService {
|
||||
}
|
||||
|
||||
private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string {
|
||||
return `EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_act_assets context_filter_link
|
||||
INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id
|
||||
WHERE context_filter_link.act_id = act.id
|
||||
AND context_filter_link.included = true
|
||||
AND context_filter_asset.${column} = ${parameter}::uuid
|
||||
)`;
|
||||
// Reports are historical documents. Their Area/Company filters must use the
|
||||
// parent Inspection snapshot, never the mutable/current Inventory context.
|
||||
return `visit.${column} = ${parameter}::uuid`;
|
||||
}
|
||||
|
||||
private contextSelect(): string {
|
||||
return `
|
||||
SELECT
|
||||
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
||||
'id',company.id,'code',company.code,'name',company.name
|
||||
)) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies,
|
||||
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
|
||||
'id',area.id,'code',area.code,'name',area.name
|
||||
)) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas
|
||||
FROM inspection_act_assets context_link
|
||||
INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id
|
||||
LEFT JOIN assets company ON company.id=context_asset.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=context_asset.operational_area_id
|
||||
WHERE context_link.act_id=act.id AND context_link.included=true
|
||||
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
||||
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
|
||||
) END AS companies,
|
||||
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
||||
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
|
||||
) END AS areas
|
||||
FROM inspection_visits context_visit
|
||||
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
|
||||
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
|
||||
WHERE context_visit.id=act.visit_id
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,9 +66,6 @@ interface LockedFindingRow {
|
||||
assetId: string;
|
||||
assetCode: string;
|
||||
assetName: string;
|
||||
companyId: string | null;
|
||||
companyCode: string | null;
|
||||
companyName: string | null;
|
||||
areaId: string | null;
|
||||
areaCode: string | null;
|
||||
areaName: string | null;
|
||||
@@ -76,6 +73,12 @@ interface LockedFindingRow {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface PlannedOperatorRow {
|
||||
companyId: string;
|
||||
companyCode: string;
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InspectionVerificationsService {
|
||||
constructor(
|
||||
@@ -142,8 +145,19 @@ export class InspectionVerificationsService {
|
||||
INNER JOIN inspection_visits source_visit ON source_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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT operator_company.id,operator_company.code,operator_company.name
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets operator_company ON operator_company.id=relation.company_id
|
||||
WHERE relation.area_id=asset.operational_area_id
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_from <= CURRENT_TIMESTAMP
|
||||
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
|
||||
AND operator_company.information_status<>'INACTIVE'
|
||||
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
|
||||
LIMIT 1
|
||||
) company ON true
|
||||
${activeVisitJoin}
|
||||
`;
|
||||
|
||||
@@ -495,15 +509,11 @@ export class InspectionVerificationsService {
|
||||
finding.next_control_on AS "nextControlOn",
|
||||
asset.code AS "assetCode",
|
||||
asset.name AS "assetName",
|
||||
company.id AS "companyId",
|
||||
company.code AS "companyCode",
|
||||
company.name AS "companyName",
|
||||
area.id AS "areaId",
|
||||
area.code AS "areaCode",
|
||||
area.name AS "areaName"
|
||||
FROM inspection_findings finding
|
||||
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||
LEFT JOIN assets company ON company.id = asset.operator_company_id
|
||||
LEFT JOIN assets area ON area.id = asset.operational_area_id
|
||||
WHERE finding.id = ANY($1::uuid[])
|
||||
FOR UPDATE OF finding
|
||||
@@ -519,25 +529,51 @@ export class InspectionVerificationsService {
|
||||
message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`,
|
||||
});
|
||||
}
|
||||
const missingContext = rows.find((row) => !row.companyId || !row.areaId);
|
||||
if (missingContext) {
|
||||
const missingArea = rows.find((row) => !row.areaId);
|
||||
if (missingArea) {
|
||||
throw new BadRequestException({
|
||||
code: 'VERIFICATION_CONTEXT_REQUIRED',
|
||||
message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`,
|
||||
message: `${missingArea.code} necesita un Área física antes de planificar la visita`,
|
||||
});
|
||||
}
|
||||
const companyId = rows[0]?.companyId;
|
||||
const areaId = rows[0]?.areaId;
|
||||
if (!companyId || !areaId) {
|
||||
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' });
|
||||
if (!areaId) {
|
||||
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan un Área física' });
|
||||
}
|
||||
if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) {
|
||||
if (rows.some((row) => row.areaId !== areaId)) {
|
||||
throw new BadRequestException({
|
||||
code: 'VERIFICATION_CONTEXT_MIXED',
|
||||
message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación',
|
||||
message: 'Seleccioná hallazgos de una misma Área para crear una visita de verificación',
|
||||
});
|
||||
}
|
||||
|
||||
// A verification visit is a new operational act. Its operator is therefore
|
||||
// the Company valid for the Area at the planned visit date, never the
|
||||
// historical compatibility snapshot stored on the Inventory.
|
||||
const [operator] = await manager.query(`
|
||||
SELECT
|
||||
company.id AS "companyId",
|
||||
company.code AS "companyCode",
|
||||
company.name AS "companyName"
|
||||
FROM area_company_relations relation
|
||||
INNER JOIN assets company ON company.id=relation.company_id
|
||||
WHERE relation.area_id=$1::uuid
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_from <= $2::timestamptz
|
||||
AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz)
|
||||
AND company.information_status<>'INACTIVE'
|
||||
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
|
||||
LIMIT 1
|
||||
FOR SHARE OF relation
|
||||
`, [areaId, plannedStartAt]) as PlannedOperatorRow[];
|
||||
if (!operator) {
|
||||
throw new ConflictException({
|
||||
code: 'VERIFICATION_OPERATOR_NOT_ACTIVE',
|
||||
message: 'El Área no tiene una Operadora vigente para la fecha prevista de la verificación',
|
||||
});
|
||||
}
|
||||
const companyId = operator.companyId;
|
||||
|
||||
const activeLinks = await manager.query(`
|
||||
SELECT verification_link.finding_id AS "findingId", visit.code
|
||||
FROM inspection_finding_verification_visits verification_link
|
||||
@@ -552,7 +588,7 @@ export class InspectionVerificationsService {
|
||||
});
|
||||
}
|
||||
|
||||
const companyName = rows[0]?.companyName ?? 'Empresa';
|
||||
const companyName = operator.companyName;
|
||||
const areaName = rows[0]?.areaName ?? 'Área';
|
||||
const code = await nextInspectionVisitCode(manager, plannedStartAt);
|
||||
const findingCodes = rows.map((row) => row.code).join(', ');
|
||||
@@ -637,6 +673,7 @@ export class InspectionVerificationsService {
|
||||
areaId,
|
||||
findingIds,
|
||||
assetIds,
|
||||
operatorSource: 'area_company_relations',
|
||||
},
|
||||
}, manager);
|
||||
|
||||
@@ -647,7 +684,7 @@ export class InspectionVerificationsService {
|
||||
status: visit.status,
|
||||
plannedStartAt: visit.plannedStartAt,
|
||||
},
|
||||
company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName },
|
||||
company: { id: companyId, code: operator.companyCode, name: companyName },
|
||||
area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName },
|
||||
findingCount: findingIds.length,
|
||||
assetCount: assetIds.length,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { IsISO8601, IsUUID } from 'class-validator';
|
||||
import { IsISO8601, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateInspectionVisitDto {
|
||||
@IsUUID('4')
|
||||
operationalAreaId!: string;
|
||||
|
||||
/**
|
||||
* Yacimiento concreto de la Inspección. Se mantiene opcional sólo para no
|
||||
* romper clientes Android anteriores, que todavía abren una Inspección a
|
||||
* nivel Área; la WEB F6.1 siempre lo envía.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
scopeAssetId?: string;
|
||||
|
||||
@IsUUID('4')
|
||||
operatorCompanyId!: string;
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class OpenMobileInspectionDto {
|
||||
@IsUUID('4')
|
||||
operationalAreaId!: string;
|
||||
|
||||
@IsUUID('4')
|
||||
operatorCompanyId!: string;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsEmpty,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
@@ -17,15 +17,15 @@ export class UpdateInspectionVisitDto {
|
||||
objective?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@IsEmpty({ message: 'El Yacimiento se fija al crear la Inspección y no puede modificarse' })
|
||||
scopeAssetId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@IsEmpty({ message: 'El Área se fija al crear la Inspección y no puede modificarse' })
|
||||
operationalAreaId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@IsEmpty({ message: 'La Operadora se fija al crear la Inspección y no puede modificarse' })
|
||||
operatorCompanyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -44,6 +44,8 @@ const STRUCTURAL_CHILD: Record<string, string | undefined> = {
|
||||
instalacion: 'subinstalacion',
|
||||
};
|
||||
|
||||
const OTHER_FAMILY_SQL = "source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR source_reference LIKE 'F5:SYSTEM:OTHER:%'";
|
||||
|
||||
@Injectable()
|
||||
export class F3FieldInventoryStructureService {
|
||||
constructor(
|
||||
@@ -59,7 +61,7 @@ export class F3FieldInventoryStructureService {
|
||||
principal: AuthPrincipal,
|
||||
) {
|
||||
const base = await this.fieldInventory.types(visitId, parentId, principal) as unknown as FieldTypesBase;
|
||||
const effectiveParentId = parentId ?? base.context.area.id;
|
||||
const effectiveParentId = parentId ?? base.parent.id;
|
||||
const parent = await this.parent(effectiveParentId);
|
||||
const expectedTypeCode = STRUCTURAL_CHILD[parent.typeCode.toLowerCase()];
|
||||
const data = expectedTypeCode
|
||||
@@ -122,7 +124,20 @@ export class F3FieldInventoryStructureService {
|
||||
asset: { id: string; code: string; name: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
if (!family) return created;
|
||||
|
||||
// Área/Yacimiento remain structural context. A concrete Installation/Subinstallation
|
||||
// created in field is a real Inventory instance from the moment it is registered.
|
||||
await this.dataSource.query(`
|
||||
UPDATE assets
|
||||
SET is_inventory_instance=$3::boolean,updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=$1::uuid
|
||||
`, [
|
||||
created.asset.id,
|
||||
principal.userId,
|
||||
typeCode === 'instalacion' || typeCode === 'subinstalacion',
|
||||
]);
|
||||
|
||||
if (!family) return this.fieldInventory.detail(visitId, created.asset.id, principal);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const [current] = await manager.query(`
|
||||
@@ -189,10 +204,10 @@ export class F3FieldInventoryStructureService {
|
||||
return this.dataSource.query(`
|
||||
SELECT id,code,name,level,information_labels AS "informationLabels",
|
||||
source_reference AS "sourceReference",
|
||||
(source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
|
||||
(${OTHER_FAMILY_SQL}) AS "isOther"
|
||||
FROM inventory_families
|
||||
WHERE level='INSTALLATION' AND is_active=true
|
||||
ORDER BY (source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,name,code
|
||||
ORDER BY (${OTHER_FAMILY_SQL}) ASC,name,code
|
||||
`) as Promise<FamilyRow[]>;
|
||||
}
|
||||
if (expectedTypeCode === 'subinstalacion') {
|
||||
@@ -206,13 +221,13 @@ export class F3FieldInventoryStructureService {
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
family.information_labels AS "informationLabels",
|
||||
family.source_reference AS "sourceReference",
|
||||
(family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
|
||||
(family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') AS "isOther"
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families family ON family.id=rule.child_family_id
|
||||
WHERE rule.parent_family_id=$1::uuid
|
||||
AND family.level='SUBINSTALLATION'
|
||||
AND family.is_active=true
|
||||
ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,family.name,family.code
|
||||
ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') ASC,family.name,family.code
|
||||
`, [parent.inventoryFamilyId]) as Promise<FamilyRow[]>;
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -4,15 +4,25 @@ import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
||||
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
||||
import type { InspectionVisitView } from './inspection-visits.service';
|
||||
import type {
|
||||
InspectionChecklistItemKind,
|
||||
InspectionVisitView,
|
||||
} from './inspection-visits.service';
|
||||
import { InspectionVisitsService } from './inspection-visits.service';
|
||||
|
||||
type F4ChecklistClassification = {
|
||||
findingId: string;
|
||||
itemKind: InspectionChecklistItemKind;
|
||||
referenceOn: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capa activa F4 sobre el servicio de Inspecciones.
|
||||
*
|
||||
* Conserva las operaciones de planificación/equipo/Inventario ya probadas y aplica
|
||||
* la clasificación técnica F4 del checklist. El contrato público de Inspección ya
|
||||
* no expone título independiente ni fecha prevista de fin.
|
||||
* El checklist persistido es una generación histórica append-only. F4 necesita
|
||||
* mostrar la clasificación vigente de controles, pero una lectura nunca debe
|
||||
* reescribir esa generación. La clasificación se deriva con SELECT y se aplica
|
||||
* sólo a la vista devuelta por la API.
|
||||
*/
|
||||
@Injectable()
|
||||
export class F4InspectionVisitsService extends InspectionVisitsService {
|
||||
@@ -32,8 +42,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
|
||||
}
|
||||
|
||||
override async getById(id: string): Promise<InspectionVisitView> {
|
||||
await this.reclassifyCurrentChecklist(id);
|
||||
return this.normalizeChecklist(await super.getById(id));
|
||||
const visit = await super.getById(id);
|
||||
return this.normalizeChecklist(await this.classifyChecklistView(visit));
|
||||
}
|
||||
|
||||
override async create(
|
||||
@@ -41,9 +51,8 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<InspectionVisitView> {
|
||||
const created = await super.create(dto, principal, request);
|
||||
await this.reclassifyCurrentChecklist(created.id);
|
||||
return this.normalizeChecklist(await super.getById(created.id));
|
||||
const visit = await super.create(dto, principal, request);
|
||||
return this.normalizeChecklist(await this.classifyChecklistView(visit));
|
||||
}
|
||||
|
||||
override async generateChecklist(
|
||||
@@ -51,88 +60,106 @@ export class F4InspectionVisitsService extends InspectionVisitsService {
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<InspectionVisitView> {
|
||||
await super.generateChecklist(id, principal, request);
|
||||
await this.reclassifyCurrentChecklist(id);
|
||||
return this.normalizeChecklist(await super.getById(id));
|
||||
const visit = await super.generateChecklist(id, principal, request);
|
||||
return this.normalizeChecklist(await this.classifyChecklistView(visit));
|
||||
}
|
||||
|
||||
private normalizeChecklist<T>(visit: T): T {
|
||||
if (!visit || typeof visit !== 'object' || !('checklist' in visit)) return visit;
|
||||
const view = visit as T & InspectionVisitView;
|
||||
const items = view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE');
|
||||
const actionableAssetIds = new Set(
|
||||
items
|
||||
.filter((item) => ['VERIFICATION_OVERDUE', 'UPCOMING_CONTROL'].includes(item.itemKind))
|
||||
.map((item) => item.asset.id),
|
||||
);
|
||||
view.checklist = {
|
||||
...view.checklist,
|
||||
antecedents: items.filter((item) => item.itemKind === 'ANTECEDENT').length,
|
||||
companyOverdue: 0,
|
||||
items: view.checklist.items.filter((item) => item.itemKind !== 'COMPANY_OVERDUE'),
|
||||
verificationOverdue: items.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length,
|
||||
upcomingControls: items.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length,
|
||||
actionableAssets: actionableAssetIds.size,
|
||||
items,
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
private async reclassifyCurrentChecklist(visitId: string): Promise<void> {
|
||||
await this.f4DataSource.transaction(async (manager) => {
|
||||
const [visit] = await manager.query(`
|
||||
SELECT checklist_generation AS generation, planned_start_at AS "plannedStartAt"
|
||||
FROM inspection_visits
|
||||
WHERE id=$1
|
||||
FOR UPDATE
|
||||
`, [visitId]) as Array<{ generation: number; plannedStartAt: Date | null }>;
|
||||
if (!visit || Number(visit.generation) < 1 || !visit.plannedStartAt) return;
|
||||
/**
|
||||
* Deriva el estado F4 actual de los ítems sin mutar la generación persistida.
|
||||
* La fuente administrativa de empresa vive a nivel Acta; en este checklist de
|
||||
* campo sólo importan próximos controles y verificaciones no resueltas.
|
||||
*/
|
||||
private async classifyChecklistView(visit: InspectionVisitView): Promise<InspectionVisitView> {
|
||||
if (!visit.plannedStartAt || visit.checklist.items.length === 0) return visit;
|
||||
const findingIds = [...new Set(visit.checklist.items.map((item) => item.findingId))];
|
||||
if (findingIds.length === 0) return visit;
|
||||
const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10);
|
||||
|
||||
const targetDate = new Date(visit.plannedStartAt).toISOString().slice(0, 10);
|
||||
await manager.query(`
|
||||
UPDATE inspection_visit_checklist_items item
|
||||
SET
|
||||
item_kind = CASE
|
||||
WHEN finding.status='OPEN'
|
||||
AND finding.next_control_on IS NOT NULL
|
||||
AND finding.next_control_on < $3::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'
|
||||
THEN 'VERIFICATION_OVERDUE'
|
||||
WHEN finding.status='OPEN'
|
||||
AND finding.next_control_on IS NOT NULL
|
||||
AND finding.next_control_on BETWEEN $3::date AND ($3::date + 30)
|
||||
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'
|
||||
THEN 'UPCOMING_CONTROL'
|
||||
ELSE 'ANTECEDENT'
|
||||
END,
|
||||
reference_on = CASE
|
||||
WHEN finding.status='OPEN'
|
||||
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'
|
||||
THEN finding.next_control_on
|
||||
ELSE NULL
|
||||
END
|
||||
FROM inspection_findings finding
|
||||
WHERE item.visit_id=$1
|
||||
AND item.generation_number=$2
|
||||
AND finding.id=item.finding_id
|
||||
`, [visitId, visit.generation, targetDate]);
|
||||
});
|
||||
const classifications = (await this.f4DataSource.query(`
|
||||
SELECT
|
||||
finding.id AS "findingId",
|
||||
CASE
|
||||
WHEN finding.status='OPEN'
|
||||
AND finding.next_control_on IS NOT NULL
|
||||
AND finding.next_control_on < $2::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'
|
||||
THEN 'VERIFICATION_OVERDUE'
|
||||
WHEN finding.status='OPEN'
|
||||
AND finding.next_control_on IS NOT NULL
|
||||
AND finding.next_control_on BETWEEN $2::date AND ($2::date + 30)
|
||||
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'
|
||||
THEN 'UPCOMING_CONTROL'
|
||||
ELSE 'ANTECEDENT'
|
||||
END AS "itemKind",
|
||||
CASE
|
||||
WHEN finding.status='OPEN'
|
||||
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'
|
||||
THEN finding.next_control_on::text
|
||||
ELSE NULL
|
||||
END AS "referenceOn"
|
||||
FROM inspection_findings finding
|
||||
WHERE finding.id=ANY($1::uuid[])
|
||||
`, [findingIds, targetDate])) as F4ChecklistClassification[];
|
||||
|
||||
const byFinding = new Map(classifications.map((item) => [item.findingId, item]));
|
||||
visit.checklist = {
|
||||
...visit.checklist,
|
||||
items: visit.checklist.items.map((item) => {
|
||||
const classification = byFinding.get(item.findingId);
|
||||
return classification
|
||||
? { ...item, itemKind: classification.itemKind, referenceOn: classification.referenceOn }
|
||||
: item;
|
||||
}),
|
||||
};
|
||||
return visit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ export class FieldFindingsService {
|
||||
visit.id AS "visitId",
|
||||
visit.code AS "visitCode",
|
||||
visit.status AS "visitStatus",
|
||||
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
|
||||
visit.operational_area_id AS "areaId",
|
||||
area.code AS "areaCode",
|
||||
area.name AS "areaName",
|
||||
@@ -138,8 +139,32 @@ export class FieldFindingsService {
|
||||
asset.id AS "assetId",
|
||||
asset_type.code AS "assetTypeCode",
|
||||
asset.inventory_family_id AS "inventoryFamilyId",
|
||||
asset.operational_area_id AS "assetAreaId",
|
||||
asset.operator_company_id AS "assetCompanyId",
|
||||
(
|
||||
asset.id=COALESCE(visit.scope_asset_id, visit.operational_area_id)
|
||||
OR EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id,parent_id FROM assets WHERE id=asset.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=COALESCE(visit.scope_asset_id, visit.operational_area_id)
|
||||
LIMIT 1
|
||||
)
|
||||
) AS "insideScope",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.area_id=visit.operational_area_id
|
||||
AND relation.company_id=visit.operator_company_id
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
|
||||
AND (
|
||||
relation.valid_until IS NULL
|
||||
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
|
||||
)
|
||||
) AS "operatorRelationValid",
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_visit_members member
|
||||
@@ -190,6 +215,7 @@ export class FieldFindingsService {
|
||||
visitId: string;
|
||||
visitCode: string;
|
||||
visitStatus: string;
|
||||
scopeAssetId: string | null;
|
||||
areaId: string | null;
|
||||
areaCode: string | null;
|
||||
areaName: string | null;
|
||||
@@ -199,8 +225,8 @@ export class FieldFindingsService {
|
||||
assetId: string | null;
|
||||
assetTypeCode: string | null;
|
||||
inventoryFamilyId: string | null;
|
||||
assetAreaId: string | null;
|
||||
assetCompanyId: string | null;
|
||||
insideScope: boolean;
|
||||
operatorRelationValid: boolean;
|
||||
assigned: boolean;
|
||||
selected: boolean;
|
||||
captureRequired: boolean;
|
||||
@@ -230,28 +256,35 @@ export class FieldFindingsService {
|
||||
message: 'La inspección no tiene Área y Operadora definidas',
|
||||
});
|
||||
}
|
||||
if (!row.operatorRelationValid) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_FINDING_OPERATOR_RELATION_INVALID',
|
||||
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
|
||||
});
|
||||
}
|
||||
if (!row.assetId) {
|
||||
throw new NotFoundException({
|
||||
code: 'FIELD_FINDING_INVENTORY_NOT_FOUND',
|
||||
message: 'Registro de Inventario no encontrado',
|
||||
});
|
||||
}
|
||||
if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) {
|
||||
const assetTypeCode = String(row.assetTypeCode).toLowerCase();
|
||||
if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED',
|
||||
message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento',
|
||||
code: 'FIELD_FINDING_TARGET_LEVEL_INVALID',
|
||||
message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación',
|
||||
});
|
||||
}
|
||||
if (!row.inventoryFamilyId) {
|
||||
if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_FINDING_FAMILY_REQUIRED',
|
||||
message: 'La Instalación/Subinstalación debe tener una familia técnica u Otro antes de registrar Hallazgos',
|
||||
message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos',
|
||||
});
|
||||
}
|
||||
if (row.assetAreaId !== row.areaId || row.assetCompanyId !== row.companyId) {
|
||||
if (!row.insideScope) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_FINDING_INVENTORY_OUTSIDE_CONTEXT',
|
||||
message: 'El Inventario no pertenece al Área y Operadora de esta inspección',
|
||||
message: 'El Inventario no pertenece al Yacimiento definido como alcance de esta inspección',
|
||||
});
|
||||
}
|
||||
if (!row.selected) {
|
||||
|
||||
@@ -23,12 +23,14 @@ interface MobileVisitContext {
|
||||
code: string;
|
||||
status: string;
|
||||
areaId: string;
|
||||
scopeAssetId: string;
|
||||
areaCode: string;
|
||||
areaName: string;
|
||||
companyId: string;
|
||||
companyCode: string;
|
||||
companyName: string;
|
||||
assigned: boolean;
|
||||
operatorRelationValid: boolean;
|
||||
}
|
||||
|
||||
interface FieldDiscoveryCreated {
|
||||
@@ -79,11 +81,23 @@ export class FieldInventoryService {
|
||||
const context = await this.requireVisitContext(visitId, principal, false);
|
||||
if (query.parentId) await this.requireParentInContext(query.parentId, context);
|
||||
|
||||
const args: unknown[] = [context.areaId, context.companyId, visitId];
|
||||
// Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope.
|
||||
const args: unknown[] = [context.scopeAssetId, visitId];
|
||||
const conditions = [
|
||||
'asset.operational_area_id = $1::uuid',
|
||||
'asset.operator_company_id = $2::uuid',
|
||||
"asset.information_status <> 'INACTIVE'",
|
||||
`(
|
||||
asset.id = $1::uuid
|
||||
OR EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id,parent_id FROM assets WHERE id=asset.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=$1::uuid LIMIT 1
|
||||
)
|
||||
)`,
|
||||
"(asset.is_inventory_instance=true OR lower(type.code)='yacimiento')",
|
||||
];
|
||||
|
||||
if (query.search?.trim()) {
|
||||
@@ -129,13 +143,13 @@ export class FieldInventoryService {
|
||||
) END AS parent,
|
||||
EXISTS (
|
||||
SELECT 1 FROM inspection_visit_assets link
|
||||
WHERE link.visit_id = $3::uuid
|
||||
WHERE link.visit_id = $2::uuid
|
||||
AND link.asset_id = asset.id
|
||||
AND link.included = true
|
||||
) AS "selectedInInspection",
|
||||
EXISTS (
|
||||
SELECT 1 FROM asset_field_discoveries discovery
|
||||
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
|
||||
WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
|
||||
) AS "captureRequired",
|
||||
EXISTS (
|
||||
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id
|
||||
@@ -143,26 +157,26 @@ export class FieldInventoryService {
|
||||
(
|
||||
SELECT COUNT(*)::integer
|
||||
FROM asset_field_capture_events capture
|
||||
WHERE capture.visit_id = $3::uuid
|
||||
WHERE capture.visit_id = $2::uuid
|
||||
AND capture.asset_id = asset.id
|
||||
AND capture.event_type = 'PHOTO'
|
||||
) AS "fieldPhotoCount",
|
||||
(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM asset_field_discoveries discovery
|
||||
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
|
||||
WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
|
||||
)
|
||||
OR (
|
||||
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM asset_field_capture_events capture
|
||||
WHERE capture.visit_id = $3::uuid
|
||||
WHERE capture.visit_id = $2::uuid
|
||||
AND capture.asset_id = asset.id
|
||||
AND capture.event_type = 'CREATED'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM asset_field_capture_events capture
|
||||
WHERE capture.visit_id = $3::uuid
|
||||
WHERE capture.visit_id = $2::uuid
|
||||
AND capture.asset_id = asset.id
|
||||
AND capture.event_type = 'PHOTO'
|
||||
)
|
||||
@@ -185,7 +199,7 @@ export class FieldInventoryService {
|
||||
principal: AuthPrincipal,
|
||||
) {
|
||||
const context = await this.requireVisitContext(visitId, principal, false);
|
||||
const effectiveParentId = parentId ?? context.areaId;
|
||||
const effectiveParentId = parentId ?? context.scopeAssetId;
|
||||
const parent = await this.requireParentInContext(effectiveParentId, context);
|
||||
|
||||
const data = await this.dataSource.query(`
|
||||
@@ -259,7 +273,7 @@ export class FieldInventoryService {
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const context = await this.requireVisitContext(visitId, principal, true);
|
||||
const parentId = dto.parentId ?? context.areaId;
|
||||
const parentId = dto.parentId ?? context.scopeAssetId;
|
||||
await this.requireParentInContext(parentId, context);
|
||||
|
||||
const capturedAt = new Date(dto.deviceCapturedAt);
|
||||
@@ -279,6 +293,7 @@ export class FieldInventoryService {
|
||||
typeId: dto.typeId,
|
||||
parentId,
|
||||
operationalAreaId: context.areaId,
|
||||
// Compatibility-only creation snapshot. Membership never depends on it.
|
||||
operatorCompanyId: context.companyId,
|
||||
description: dto.description ?? null,
|
||||
discoveryNotes: dto.discoveryNotes ?? null,
|
||||
@@ -398,6 +413,7 @@ export class FieldInventoryService {
|
||||
visit.id,
|
||||
visit.code,
|
||||
visit.status,
|
||||
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",
|
||||
visit.operational_area_id AS "areaId",
|
||||
area.code AS "areaCode",
|
||||
area.name AS "areaName",
|
||||
@@ -413,7 +429,19 @@ export class FieldInventoryService {
|
||||
AND member.user_id = $2::uuid
|
||||
AND member.included = true
|
||||
)
|
||||
) AS assigned
|
||||
) AS assigned,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM area_company_relations relation
|
||||
WHERE relation.area_id=visit.operational_area_id
|
||||
AND relation.company_id=visit.operator_company_id
|
||||
AND relation.relation_role='OPERATOR'
|
||||
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
|
||||
AND (
|
||||
relation.valid_until IS NULL
|
||||
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
|
||||
)
|
||||
) AS "operatorRelationValid"
|
||||
FROM inspection_visits visit
|
||||
LEFT JOIN assets area ON area.id = visit.operational_area_id
|
||||
LEFT JOIN assets company ON company.id = visit.operator_company_id
|
||||
@@ -429,6 +457,12 @@ export class FieldInventoryService {
|
||||
message: 'La inspección no tiene Área y Operadora definidas',
|
||||
});
|
||||
}
|
||||
if (!context.operatorRelationValid) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_INVENTORY_OPERATOR_RELATION_INVALID',
|
||||
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
|
||||
});
|
||||
}
|
||||
if (!context.assigned) {
|
||||
throw new ConflictException({
|
||||
code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED',
|
||||
@@ -453,6 +487,7 @@ export class FieldInventoryService {
|
||||
private publicContext(context: MobileVisitContext) {
|
||||
return {
|
||||
inspection: { id: context.id, code: context.code, status: context.status },
|
||||
scopeAssetId: context.scopeAssetId,
|
||||
area: { id: context.areaId, code: context.areaCode, name: context.areaName },
|
||||
operatorCompany: {
|
||||
id: context.companyId,
|
||||
@@ -469,18 +504,26 @@ export class FieldInventoryService {
|
||||
asset.code,
|
||||
asset.name,
|
||||
asset.asset_type_id AS "typeId",
|
||||
asset.operational_area_id AS "areaId",
|
||||
asset.operator_company_id AS "companyId"
|
||||
(
|
||||
asset.id=$2::uuid
|
||||
OR EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id,parent_id FROM assets WHERE id=asset.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=$2::uuid LIMIT 1
|
||||
)
|
||||
) AS "insideScope"
|
||||
FROM assets asset
|
||||
WHERE asset.id = $1::uuid
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
`, [parentId])) as Array<{
|
||||
`, [parentId, context.scopeAssetId])) as Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
typeId: string;
|
||||
areaId: string | null;
|
||||
companyId: string | null;
|
||||
insideScope: boolean;
|
||||
}>;
|
||||
|
||||
if (!parent) {
|
||||
@@ -489,13 +532,10 @@ export class FieldInventoryService {
|
||||
message: 'La ubicación padre no existe',
|
||||
});
|
||||
}
|
||||
if (
|
||||
parent.id !== context.areaId
|
||||
&& (parent.areaId !== context.areaId || parent.companyId !== context.companyId)
|
||||
) {
|
||||
if (!parent.insideScope) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
|
||||
message: 'La ubicación padre no pertenece al Área y Operadora de la inspección',
|
||||
message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección',
|
||||
});
|
||||
}
|
||||
return parent;
|
||||
@@ -503,16 +543,28 @@ export class FieldInventoryService {
|
||||
|
||||
private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
|
||||
const [asset] = (await this.dataSource.query(`
|
||||
SELECT id,
|
||||
operational_area_id AS "areaId",
|
||||
operator_company_id AS "companyId"
|
||||
FROM assets
|
||||
WHERE id = $1::uuid
|
||||
AND information_status <> 'INACTIVE'
|
||||
`, [assetId])) as Array<{
|
||||
SELECT
|
||||
asset.id,
|
||||
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
|
||||
(
|
||||
asset.id=$2::uuid
|
||||
OR EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id,parent_id FROM assets WHERE id=asset.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=$2::uuid LIMIT 1
|
||||
)
|
||||
) AS "insideScope"
|
||||
FROM assets asset
|
||||
JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE asset.id = $1::uuid
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
`, [assetId, context.scopeAssetId])) as Array<{
|
||||
id: string;
|
||||
areaId: string | null;
|
||||
companyId: string | null;
|
||||
eligibleTarget: boolean;
|
||||
insideScope: boolean;
|
||||
}>;
|
||||
|
||||
if (!asset) {
|
||||
@@ -521,10 +573,16 @@ export class FieldInventoryService {
|
||||
message: 'Registro de Inventario no encontrado',
|
||||
});
|
||||
}
|
||||
if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) {
|
||||
if (!asset.insideScope) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
|
||||
message: 'El registro no pertenece al Área y Operadora de esta inspección',
|
||||
message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección',
|
||||
});
|
||||
}
|
||||
if (!asset.eligibleTarget) {
|
||||
throw new BadRequestException({
|
||||
code: 'FIELD_INVENTORY_NOT_OPERATIONAL_TARGET',
|
||||
message: 'El registro no es un Yacimiento ni una instancia real de Inventario',
|
||||
});
|
||||
}
|
||||
return asset;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { AuditAction, InspectionVisit, InspectionVisitStatus } from '../database/entities';
|
||||
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
||||
import { nextInspectionVisitCode } from './inspection-visit-code';
|
||||
import { validateInspectionPlanningScope } from './inspection-planning-scope';
|
||||
import { InspectionVisitsService } from './inspection-visits.service';
|
||||
|
||||
/**
|
||||
* Creación WEB. La ubicación territorial es Departamento → Área → Yacimiento.
|
||||
* Empresa y Tipo de concesión pertenecen directamente al Yacimiento; por eso
|
||||
* la Operadora elegida debe coincidir con el Yacimiento seleccionado.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InspectionPlanningCreateService {
|
||||
private readonly logger = new Logger(InspectionPlanningCreateService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly visits: InspectionVisitsService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreateInspectionVisitDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
const scopeAssetId = dto.scopeAssetId;
|
||||
if (!scopeAssetId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_YACIMIENTO_REQUIRED',
|
||||
message: 'Seleccioná el Yacimiento de la Inspección',
|
||||
});
|
||||
}
|
||||
const plannedStartAt = new Date(dto.plannedStartAt);
|
||||
if (!Number.isFinite(plannedStartAt.getTime())) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_START_DATE_REQUIRED',
|
||||
message: 'Indicá una fecha y hora prevista de inicio válida',
|
||||
});
|
||||
}
|
||||
|
||||
let stage = 'transaction:start';
|
||||
try {
|
||||
const visitId = await this.dataSource.transaction(async (manager) => {
|
||||
stage = 'scope:validate';
|
||||
await validateInspectionPlanningScope(manager, dto.operationalAreaId, scopeAssetId);
|
||||
|
||||
stage = 'operator:validate';
|
||||
const [context] = await manager.query(`
|
||||
SELECT yacimiento.id
|
||||
FROM assets yacimiento
|
||||
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
|
||||
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
|
||||
WHERE yacimiento.id=$1::uuid
|
||||
AND yacimiento.parent_id=$2::uuid
|
||||
AND yacimiento.operator_company_id=$3::uuid
|
||||
AND lower(yacimiento_type.code)='yacimiento'
|
||||
AND yacimiento_type.is_active=true
|
||||
AND yacimiento.information_status<>'INACTIVE'
|
||||
AND yacimiento.concession_type_id IS NOT NULL
|
||||
AND company_type.operational_role='COMPANY'
|
||||
AND company_type.is_active=true
|
||||
AND company.information_status<>'INACTIVE'
|
||||
LIMIT 1
|
||||
`, [scopeAssetId, dto.operationalAreaId, dto.operatorCompanyId]) as Array<{ id: string }>;
|
||||
if (!context) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_OPERATOR_NOT_RELATED_TO_YACIMIENTO',
|
||||
message: 'La Empresa seleccionada no es la Empresa relacionada con el Yacimiento',
|
||||
});
|
||||
}
|
||||
|
||||
stage = 'inspector:validate';
|
||||
const [inspector] = await manager.query(`
|
||||
SELECT user_account.id
|
||||
FROM users user_account
|
||||
WHERE user_account.id=$1::uuid
|
||||
AND user_account.status='ACTIVE'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_roles user_role
|
||||
INNER JOIN role_permissions role_permission ON role_permission.role_id=user_role.role_id
|
||||
INNER JOIN permissions permission ON permission.id=role_permission.permission_id
|
||||
WHERE user_role.user_id=user_account.id
|
||||
AND permission.code='inspections.execute'
|
||||
)
|
||||
LIMIT 1
|
||||
`, [dto.leadInspectorUserId]) as Array<{ id: string }>;
|
||||
if (!inspector) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_MEMBER_INVALID',
|
||||
message: 'El Inspector responsable no está activo o no tiene permiso de ejecución',
|
||||
});
|
||||
}
|
||||
|
||||
stage = 'code:next';
|
||||
const code = await nextInspectionVisitCode(manager, plannedStartAt);
|
||||
const visit = manager.getRepository(InspectionVisit).create({
|
||||
code,
|
||||
objective: null,
|
||||
status: InspectionVisitStatus.DRAFT,
|
||||
scopeAssetId,
|
||||
operationalAreaId: dto.operationalAreaId,
|
||||
operatorCompanyId: dto.operatorCompanyId,
|
||||
leadInspectorUserId: dto.leadInspectorUserId,
|
||||
plannedStartAt,
|
||||
actualStartedAt: null,
|
||||
actualClosedAt: null,
|
||||
instructions: null,
|
||||
cancellationReason: null,
|
||||
checklistGeneration: 1,
|
||||
checklistGeneratedAt: new Date(),
|
||||
createdBy: principal.userId,
|
||||
updatedBy: principal.userId,
|
||||
});
|
||||
|
||||
stage = 'visit:save';
|
||||
await manager.getRepository(InspectionVisit).save(visit);
|
||||
|
||||
stage = 'member:insert';
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_visit_members (visit_id,user_id,included,assigned_by)
|
||||
VALUES ($1::uuid,$2::uuid,true,$3::uuid)
|
||||
`, [visit.id, dto.leadInspectorUserId, principal.userId]);
|
||||
|
||||
stage = 'checklist:insert';
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_visit_checklist_items (
|
||||
visit_id,generation_number,finding_id,asset_id,item_kind,
|
||||
reference_on,finding_status,finding_code,finding_title,severity
|
||||
)
|
||||
SELECT
|
||||
$1::uuid,1,finding.id,finding.asset_id,
|
||||
CASE
|
||||
WHEN finding.status='OPEN'
|
||||
AND finding.next_control_on IS NOT NULL
|
||||
AND finding.next_control_on < $4::date THEN 'VERIFICATION_OVERDUE'
|
||||
WHEN finding.status='OPEN'
|
||||
AND finding.next_control_on IS NOT NULL
|
||||
AND finding.next_control_on BETWEEN $4::date AND ($4::date + 30) THEN 'UPCOMING_CONTROL'
|
||||
ELSE 'ANTECEDENT'
|
||||
END,
|
||||
CASE WHEN finding.next_control_on IS NOT NULL THEN finding.next_control_on ELSE NULL END,
|
||||
finding.status,finding.code,finding.title,finding.severity
|
||||
FROM inspection_findings finding
|
||||
INNER JOIN assets inventory ON inventory.id=finding.asset_id
|
||||
WHERE inventory.operational_area_id=$2::uuid
|
||||
AND finding.status<>'VOIDED'
|
||||
AND EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT current_asset.id,current_asset.parent_id
|
||||
FROM assets current_asset WHERE current_asset.id=finding.asset_id
|
||||
UNION ALL
|
||||
SELECT parent.id,parent.parent_id
|
||||
FROM assets parent INNER JOIN ancestors child ON child.parent_id=parent.id
|
||||
)
|
||||
SELECT 1 FROM ancestors WHERE id=$3::uuid LIMIT 1
|
||||
)
|
||||
ORDER BY finding.created_at,finding.id
|
||||
`, [visit.id, dto.operationalAreaId, scopeAssetId, plannedStartAt.toISOString().slice(0, 10)]);
|
||||
|
||||
stage = 'checklist:assets';
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_visit_assets (
|
||||
visit_id, asset_id, included, planning_source, added_by
|
||||
)
|
||||
SELECT DISTINCT $1::uuid, item.asset_id, true, 'AUTOMATIC', $2::uuid
|
||||
FROM inspection_visit_checklist_items item
|
||||
WHERE item.visit_id=$1::uuid
|
||||
AND item.generation_number=1
|
||||
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
|
||||
ON CONFLICT (visit_id,asset_id) DO UPDATE SET
|
||||
included=true,
|
||||
planning_source=CASE
|
||||
WHEN inspection_visit_assets.planning_source='VERIFICATION' THEN 'VERIFICATION'
|
||||
ELSE 'AUTOMATIC'
|
||||
END,
|
||||
exclusion_reason=NULL,
|
||||
excluded_by=NULL,
|
||||
excluded_at=NULL,
|
||||
added_by=EXCLUDED.added_by,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
`, [visit.id, principal.userId]);
|
||||
|
||||
stage = 'checklist:asset-events';
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_visit_asset_events (
|
||||
visit_id, asset_id, event_type, reason, actor_user_id, metadata
|
||||
)
|
||||
SELECT DISTINCT
|
||||
$1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid,
|
||||
jsonb_build_object('generation',1,'source','AUTHORITATIVE_CREATE')
|
||||
FROM inspection_visit_checklist_items item
|
||||
WHERE item.visit_id=$1::uuid
|
||||
AND item.generation_number=1
|
||||
AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM inspection_visit_asset_events event
|
||||
WHERE event.visit_id=$1::uuid
|
||||
AND event.asset_id=item.asset_id
|
||||
AND event.event_type='AUTO_INCLUDED'
|
||||
)
|
||||
`, [visit.id, principal.userId]);
|
||||
|
||||
stage = 'audit:record';
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INSPECTION_VISIT_CREATED,
|
||||
entityType: 'inspection_visit',
|
||||
entityId: visit.id,
|
||||
afterData: {
|
||||
id: visit.id,
|
||||
code,
|
||||
status: visit.status,
|
||||
departmentHierarchy: 'Departamento → Área → Yacimiento',
|
||||
operationalAreaId: dto.operationalAreaId,
|
||||
scopeAssetId,
|
||||
operatorCompanyId: dto.operatorCompanyId,
|
||||
operatorSource: 'YACIMIENTO',
|
||||
plannedStartAt: plannedStartAt.toISOString(),
|
||||
leadInspectorUserId: dto.leadInspectorUserId,
|
||||
},
|
||||
}, manager);
|
||||
return visit.id;
|
||||
});
|
||||
|
||||
stage = 'view:load';
|
||||
return await this.visits.getById(visitId);
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_CODE_ALREADY_EXISTS',
|
||||
message: 'El identificador de Inspección ya existe; volvé a intentar',
|
||||
});
|
||||
}
|
||||
const technical = error instanceof Error
|
||||
? `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ''}`
|
||||
: String(error);
|
||||
this.logger.error(`inspection create failed at ${stage}: ${technical}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export interface InspectionPlanningHierarchyItem {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InspectionPlanningHierarchyService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async departments(): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT department.id, department.code, department.name
|
||||
FROM assets department
|
||||
INNER JOIN asset_types type ON type.id=department.asset_type_id
|
||||
WHERE lower(type.code)='departamento'
|
||||
AND type.is_active=true
|
||||
AND department.information_status<>'INACTIVE'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM assets area
|
||||
INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id
|
||||
WHERE area.parent_id=department.id
|
||||
AND lower(area_type.code)='area'
|
||||
AND area.information_status<>'INACTIVE'
|
||||
)
|
||||
ORDER BY department.name, department.code
|
||||
`) as InspectionPlanningHierarchyItem[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async areasForDepartment(departmentId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||
await this.requireType(departmentId, 'departamento', 'El Departamento seleccionado no es válido');
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT area.id, area.code, area.name
|
||||
FROM assets area
|
||||
INNER JOIN asset_types type ON type.id=area.asset_type_id
|
||||
WHERE area.parent_id=$1::uuid
|
||||
AND lower(type.code)='area'
|
||||
AND type.is_active=true
|
||||
AND area.information_status<>'INACTIVE'
|
||||
ORDER BY area.name, area.code
|
||||
`, [departmentId]) as InspectionPlanningHierarchyItem[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async yacimientosForArea(areaId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT yacimiento.id, yacimiento.code, yacimiento.name
|
||||
FROM assets yacimiento
|
||||
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
|
||||
WHERE yacimiento.parent_id=$1::uuid
|
||||
AND lower(type.code)='yacimiento'
|
||||
AND type.is_active=true
|
||||
AND yacimiento.information_status<>'INACTIVE'
|
||||
ORDER BY yacimiento.name, yacimiento.code
|
||||
`, [areaId]) as InspectionPlanningHierarchyItem[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
async operatorsForArea(
|
||||
areaId: string,
|
||||
at?: string,
|
||||
): Promise<{ data: InspectionPlanningHierarchyItem[] }> {
|
||||
await this.requireType(areaId, 'area', 'El Área seleccionada no es válida');
|
||||
if (at) {
|
||||
const effectiveAt = new Date(at);
|
||||
if (!Number.isFinite(effectiveAt.getTime())) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_PLANNING_DATE_INVALID',
|
||||
message: 'La fecha de planificación no es válida',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Modelo autoritativo: la Empresa pertenece al Yacimiento. Para mantener el
|
||||
// selector de planificación por Área, se proyectan las Empresas de todos los
|
||||
// Yacimientos activos que pertenecen a esa Área.
|
||||
const data = await this.dataSource.query(`
|
||||
SELECT DISTINCT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name
|
||||
FROM assets yacimiento
|
||||
INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id
|
||||
INNER JOIN assets company ON company.id=yacimiento.operator_company_id
|
||||
INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id
|
||||
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
|
||||
WHERE yacimiento.parent_id=$1::uuid
|
||||
AND lower(yacimiento_type.code)='yacimiento'
|
||||
AND yacimiento_type.is_active=true
|
||||
AND yacimiento.information_status<>'INACTIVE'
|
||||
AND company_type.operational_role='COMPANY'
|
||||
AND company_type.is_active=true
|
||||
AND company.information_status<>'INACTIVE'
|
||||
ORDER BY name, company.code
|
||||
`, [areaId]) as InspectionPlanningHierarchyItem[];
|
||||
return { data };
|
||||
}
|
||||
|
||||
private async requireType(id: string, typeCode: string, message: string): Promise<void> {
|
||||
const [row] = await this.dataSource.query(`
|
||||
SELECT 1
|
||||
FROM assets asset
|
||||
INNER JOIN asset_types type ON type.id=asset.asset_type_id
|
||||
WHERE asset.id=$1::uuid
|
||||
AND lower(type.code)=lower($2)
|
||||
AND type.is_active=true
|
||||
AND asset.information_status<>'INACTIVE'
|
||||
LIMIT 1
|
||||
`, [id, typeCode]) as Array<{ '?column?': number }>;
|
||||
if (!row) {
|
||||
throw new BadRequestException({ code: 'INSPECTION_PLANNING_HIERARCHY_INVALID', message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
|
||||
/**
|
||||
* F6.1 keeps territorial levels explicit. operationalAreaId freezes the Area
|
||||
* context while scopeAssetId identifies the Yacimiento inspected inside it.
|
||||
* Legacy/mobile callers may still use the Area itself as scope until Android
|
||||
* supplies a Yacimiento explicitly.
|
||||
*/
|
||||
export async function validateInspectionPlanningScope(
|
||||
manager: EntityManager,
|
||||
operationalAreaId: string,
|
||||
scopeAssetId: string,
|
||||
): Promise<void> {
|
||||
if (scopeAssetId === operationalAreaId) return;
|
||||
|
||||
const [scope] = await manager.query(`
|
||||
SELECT 1
|
||||
FROM assets yacimiento
|
||||
INNER JOIN asset_types type ON type.id=yacimiento.asset_type_id
|
||||
WHERE yacimiento.id=$1::uuid
|
||||
AND yacimiento.parent_id=$2::uuid
|
||||
AND lower(type.code)='yacimiento'
|
||||
AND type.is_active=true
|
||||
AND yacimiento.information_status<>'INACTIVE'
|
||||
LIMIT 1
|
||||
`, [scopeAssetId, operationalAreaId]) as Array<{ '?column?': number }>;
|
||||
|
||||
if (!scope) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_YACIMIENTO_OUTSIDE_AREA',
|
||||
message: 'El Yacimiento seleccionado no pertenece al Área indicada',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
|
||||
|
||||
@Controller('inspection-visits')
|
||||
export class InspectionPreventiveCandidatesController {
|
||||
constructor(private readonly candidates: InspectionPreventiveCandidatesService) {}
|
||||
|
||||
@Get(':id/preventive-candidates')
|
||||
@RequirePermissions('inspections.read')
|
||||
list(
|
||||
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
|
||||
@Query('search') search?: string,
|
||||
) {
|
||||
return this.candidates.list(id, search);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
export interface InspectionPreventiveCandidate {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
typeName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InspectionPreventiveCandidatesService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async list(visitId: string, search?: string): Promise<{ data: InspectionPreventiveCandidate[] }> {
|
||||
const [context] = (await this.dataSource.query(`
|
||||
SELECT
|
||||
visit.operational_area_id AS "operationalAreaId",
|
||||
COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId"
|
||||
FROM inspection_visits visit
|
||||
WHERE visit.id = $1::uuid
|
||||
`, [visitId])) as Array<{
|
||||
operationalAreaId: string | null;
|
||||
scopeAssetId: string | null;
|
||||
}>;
|
||||
|
||||
if (!context) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_VISIT_NOT_FOUND',
|
||||
message: 'Visita de inspección no encontrada',
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.operationalAreaId || !context.scopeAssetId) return { data: [] };
|
||||
|
||||
const term = search?.trim().slice(0, 200) ?? '';
|
||||
const rows = (await this.dataSource.query(`
|
||||
WITH RECURSIVE scope_tree AS (
|
||||
SELECT root.id
|
||||
FROM assets root
|
||||
WHERE root.id = $2::uuid
|
||||
UNION ALL
|
||||
SELECT child.id
|
||||
FROM assets child
|
||||
INNER JOIN scope_tree parent_scope ON parent_scope.id = child.parent_id
|
||||
)
|
||||
SELECT
|
||||
asset.id,
|
||||
asset.code,
|
||||
asset.name,
|
||||
asset_type.name AS "typeName"
|
||||
FROM scope_tree
|
||||
INNER JOIN assets asset ON asset.id = scope_tree.id
|
||||
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
||||
WHERE asset.id <> $2::uuid
|
||||
AND asset.operational_area_id = $3::uuid
|
||||
AND asset.information_status <> 'INACTIVE'
|
||||
AND lower(asset_type.code) IN ('instalacion', 'subinstalacion')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_visit_assets linked
|
||||
WHERE linked.visit_id = $1::uuid
|
||||
AND linked.asset_id = asset.id
|
||||
)
|
||||
AND (
|
||||
$4::text = ''
|
||||
OR asset.code ILIKE '%' || $4::text || '%'
|
||||
OR asset.name ILIKE '%' || $4::text || '%'
|
||||
OR COALESCE(asset.common_name, '') ILIKE '%' || $4::text || '%'
|
||||
)
|
||||
ORDER BY asset_type.name, asset.name, asset.code
|
||||
LIMIT 100
|
||||
`, [visitId, context.scopeAssetId, context.operationalAreaId, term])) as InspectionPreventiveCandidate[];
|
||||
|
||||
return { data: rows };
|
||||
}
|
||||
}
|
||||
@@ -295,11 +295,13 @@ export class InspectionVisitLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
// El contexto operativo de una Inspección es Área + Operadora vigente. El
|
||||
// Yacimiento pertenece al alcance físico y nunca reemplaza al Área raíz.
|
||||
private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise<void> {
|
||||
if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_PLANNING_INCOMPLETE',
|
||||
message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar',
|
||||
message: 'Definí Área, Operadora, Inspector responsable y fecha de inicio antes de planificar',
|
||||
});
|
||||
}
|
||||
if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) {
|
||||
@@ -320,7 +322,7 @@ export class InspectionVisitLifecycleService {
|
||||
if (!relation) {
|
||||
throw new ConflictException({
|
||||
code: 'INSPECTION_OPERATOR_RELATION_INVALID',
|
||||
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento',
|
||||
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área',
|
||||
});
|
||||
}
|
||||
const [inspector] = await manager.query(`
|
||||
|
||||
@@ -13,14 +13,18 @@ import {
|
||||
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 { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
||||
import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto';
|
||||
import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
|
||||
import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
||||
import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto';
|
||||
import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
||||
import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto';
|
||||
import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
|
||||
import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
||||
import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
||||
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
|
||||
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
|
||||
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
|
||||
import { InspectionVisitsService } from './inspection-visits.service';
|
||||
|
||||
@@ -29,6 +33,8 @@ export class InspectionVisitsController {
|
||||
constructor(
|
||||
private readonly visits: InspectionVisitsService,
|
||||
private readonly lifecycle: InspectionVisitLifecycleService,
|
||||
private readonly planningHierarchy: InspectionPlanningHierarchyService,
|
||||
private readonly planningCreate: InspectionPlanningCreateService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -43,6 +49,29 @@ export class InspectionVisitsController {
|
||||
return this.visits.listAssignees();
|
||||
}
|
||||
|
||||
@Get('planning-context/departments')
|
||||
@RequirePermissions('inspections.read')
|
||||
planningDepartments() {
|
||||
return this.planningHierarchy.departments();
|
||||
}
|
||||
|
||||
@Get('planning-context/departments/:departmentId/areas')
|
||||
@RequirePermissions('inspections.read')
|
||||
planningAreasForDepartment(
|
||||
@Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string,
|
||||
) {
|
||||
return this.planningHierarchy.areasForDepartment(departmentId);
|
||||
}
|
||||
|
||||
@Get('planning-context/areas/:areaId/yacimientos')
|
||||
@RequirePermissions('inspections.read')
|
||||
planningYacimientos(
|
||||
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||
) {
|
||||
return this.planningHierarchy.yacimientosForArea(areaId);
|
||||
}
|
||||
|
||||
/** Compatibilidad con clientes anteriores: lista todas las Áreas. */
|
||||
@Get('planning-context/areas')
|
||||
@RequirePermissions('inspections.read')
|
||||
planningAreas() {
|
||||
@@ -53,10 +82,48 @@ export class InspectionVisitsController {
|
||||
@RequirePermissions('inspections.read')
|
||||
planningOperators(
|
||||
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||
@Query('at') at?: string,
|
||||
) {
|
||||
return this.planningHierarchy.operatorsForArea(areaId, at);
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/areas')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) {
|
||||
assertMobileInspector(principal);
|
||||
return this.visits.listPlanningAreas();
|
||||
}
|
||||
|
||||
@Get('mobile/planning-context/areas/:areaId/operators')
|
||||
@RequirePermissions('inspections.execute')
|
||||
mobilePlanningOperators(
|
||||
@Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
return this.visits.listPlanningOperators(areaId);
|
||||
}
|
||||
|
||||
@Post('mobile/open')
|
||||
@RequirePermissions('inspections.execute')
|
||||
async openMobile(
|
||||
@Body() dto: OpenMobileInspectionDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
assertMobileInspector(principal);
|
||||
// Android conserva por compatibilidad el alcance a nivel Área hasta que su
|
||||
// flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1.
|
||||
const created = await this.visits.create({
|
||||
operationalAreaId: dto.operationalAreaId,
|
||||
operatorCompanyId: dto.operatorCompanyId,
|
||||
plannedStartAt: new Date().toISOString(),
|
||||
leadInspectorUserId: principal.userId,
|
||||
}, principal, request);
|
||||
await this.lifecycle.plan(created.id, principal, request);
|
||||
return this.lifecycle.start(created.id, principal, request);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermissions('inspections.read')
|
||||
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
|
||||
@@ -70,7 +137,7 @@ export class InspectionVisitsController {
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.visits.create(dto, principal, request);
|
||||
return this.planningCreate.create(dto, principal, request);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
|
||||
@@ -8,15 +8,27 @@ import { FieldFindingsController } from './field-findings.controller';
|
||||
import { FieldFindingsService } from './field-findings.service';
|
||||
import { FieldInventoryController } from './field-inventory.controller';
|
||||
import { FieldInventoryService } from './field-inventory.service';
|
||||
import { InspectionPlanningCreateService } from './inspection-planning-create.service';
|
||||
import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service';
|
||||
import { InspectionPreventiveCandidatesController } from './inspection-preventive-candidates.controller';
|
||||
import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service';
|
||||
import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service';
|
||||
import { InspectionVisitsController } from './inspection-visits.controller';
|
||||
import { InspectionVisitsService } from './inspection-visits.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, AssetMasterModule, InspectionFindingsModule],
|
||||
controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController],
|
||||
controllers: [
|
||||
InspectionVisitsController,
|
||||
InspectionPreventiveCandidatesController,
|
||||
FieldInventoryController,
|
||||
FieldFindingsController,
|
||||
],
|
||||
providers: [
|
||||
{ provide: InspectionVisitsService, useClass: F4InspectionVisitsService },
|
||||
InspectionPlanningCreateService,
|
||||
InspectionPlanningHierarchyService,
|
||||
InspectionPreventiveCandidatesService,
|
||||
InspectionVisitLifecycleService,
|
||||
FieldInventoryService,
|
||||
F3FieldInventoryStructureService,
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-v
|
||||
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
||||
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
||||
import { nextInspectionVisitCode } from './inspection-visit-code';
|
||||
import { validateInspectionPlanningScope } from './inspection-planning-scope';
|
||||
|
||||
export interface InspectionPerson {
|
||||
id: string;
|
||||
@@ -312,7 +313,7 @@ export class InspectionVisitsService {
|
||||
}
|
||||
const operationalAreaId = dto.operationalAreaId;
|
||||
const operatorCompanyId = dto.operatorCompanyId;
|
||||
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId);
|
||||
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt);
|
||||
await this.requireAsset(manager, operationalAreaId);
|
||||
await this.requireInspectors(manager, [dto.leadInspectorUserId]);
|
||||
const code = await nextInspectionVisitCode(manager, plannedStartAt);
|
||||
@@ -382,29 +383,24 @@ export class InspectionVisitsService {
|
||||
? visit.operatorCompanyId
|
||||
: dto.operatorCompanyId;
|
||||
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
|
||||
const nextScope = contextTouched
|
||||
? nextAreaId
|
||||
: dto.scopeAssetId === undefined
|
||||
? visit.scopeAssetId
|
||||
: dto.scopeAssetId;
|
||||
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);
|
||||
const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId;
|
||||
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart);
|
||||
await this.requireAsset(manager, nextScope);
|
||||
if (contextTouched && nextAreaId && nextCompanyId) {
|
||||
await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId);
|
||||
} else if (dto.scopeAssetId !== undefined) {
|
||||
if (nextAreaId && nextScope) {
|
||||
await validateInspectionPlanningScope(manager, nextAreaId, nextScope);
|
||||
await this.assertCurrentAssetsInScope(manager, id, nextScope);
|
||||
}
|
||||
if (dto.objective !== undefined) visit.objective = dto.objective;
|
||||
if (contextTouched) {
|
||||
visit.operationalAreaId = nextAreaId;
|
||||
visit.operatorCompanyId = nextCompanyId;
|
||||
visit.scopeAssetId = nextAreaId;
|
||||
} else if (dto.scopeAssetId !== undefined) {
|
||||
}
|
||||
if (dto.scopeAssetId !== undefined) {
|
||||
visit.scopeAssetId = dto.scopeAssetId;
|
||||
}
|
||||
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
|
||||
if (dto.instructions !== undefined) visit.instructions = dto.instructions;
|
||||
if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
|
||||
if (contextTouched || dto.scopeAssetId !== undefined || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;
|
||||
visit.updatedBy = principal.userId;
|
||||
await manager.getRepository(InspectionVisit).save(visit);
|
||||
const updated = await this.loadView(manager, id);
|
||||
@@ -463,11 +459,10 @@ export class InspectionVisitsService {
|
||||
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
|
||||
});
|
||||
}
|
||||
await this.assertAssetsMatchContext(
|
||||
await this.assertAssetsInScope(
|
||||
manager,
|
||||
additions,
|
||||
visit.operationalAreaId,
|
||||
visit.operatorCompanyId,
|
||||
visit.scopeAssetId ?? visit.operationalAreaId,
|
||||
);
|
||||
for (const assetId of additions) {
|
||||
await manager.query(`
|
||||
@@ -629,11 +624,10 @@ export class InspectionVisitsService {
|
||||
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
|
||||
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
|
||||
}
|
||||
await this.assertAssetsMatchContext(
|
||||
await this.assertAssetsInScope(
|
||||
manager,
|
||||
[assetId],
|
||||
visit.operationalAreaId,
|
||||
visit.operatorCompanyId,
|
||||
visit.scopeAssetId ?? visit.operationalAreaId,
|
||||
);
|
||||
await manager.query(`
|
||||
UPDATE inspection_visit_assets
|
||||
@@ -920,6 +914,7 @@ export class InspectionVisitsService {
|
||||
manager: EntityManager,
|
||||
operationalAreaId: string | null,
|
||||
operatorCompanyId: string | null,
|
||||
effectiveAt: Date | null = null,
|
||||
): Promise<void> {
|
||||
if (!operationalAreaId && !operatorCompanyId) return;
|
||||
if (!operationalAreaId || !operatorCompanyId) {
|
||||
@@ -938,8 +933,8 @@ export class InspectionVisitsService {
|
||||
WHERE relation.area_id = area.id
|
||||
AND relation.company_id = company.id
|
||||
AND relation.relation_role = 'OPERATOR'
|
||||
AND relation.valid_from <= CURRENT_TIMESTAMP
|
||||
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
|
||||
AND relation.valid_from <= $3::timestamptz
|
||||
AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)
|
||||
) AS "activeOperatorRelation"
|
||||
FROM assets area
|
||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||
@@ -951,7 +946,7 @@ export class InspectionVisitsService {
|
||||
AND company_type.operational_role = 'COMPANY'
|
||||
AND area.information_status <> 'INACTIVE'
|
||||
AND company.information_status <> 'INACTIVE'
|
||||
`, [operationalAreaId, operatorCompanyId])) as Array<{
|
||||
`, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{
|
||||
areaId: string;
|
||||
companyId: string;
|
||||
activeOperatorRelation: boolean;
|
||||
@@ -970,44 +965,6 @@ export class InspectionVisitsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAssetsMatchContext(
|
||||
manager: EntityManager,
|
||||
assetIds: string[],
|
||||
operationalAreaId: string,
|
||||
operatorCompanyId: string,
|
||||
): Promise<void> {
|
||||
if (assetIds.length === 0) return;
|
||||
const [row] = (await manager.query(`
|
||||
SELECT COUNT(*)::integer AS outside
|
||||
FROM assets asset
|
||||
WHERE asset.id = ANY($1::uuid[])
|
||||
AND (
|
||||
asset.operational_area_id IS DISTINCT FROM $2::uuid
|
||||
OR asset.operator_company_id IS DISTINCT FROM $3::uuid
|
||||
)
|
||||
`, [assetIds, operationalAreaId, operatorCompanyId])) as Array<{ outside: number }>;
|
||||
if (Number(row?.outside ?? 0) > 0) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_ASSET_OUTSIDE_OPERATIONAL_CONTEXT',
|
||||
message: 'Uno o más registros no pertenecen al Área y Operadora de la visita',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCurrentAssetsMatchContext(
|
||||
manager: EntityManager,
|
||||
visitId: string,
|
||||
operationalAreaId: string,
|
||||
operatorCompanyId: string,
|
||||
): Promise<void> {
|
||||
await this.assertAssetsMatchContext(
|
||||
manager,
|
||||
await this.activeAssetIds(manager, visitId),
|
||||
operationalAreaId,
|
||||
operatorCompanyId,
|
||||
);
|
||||
}
|
||||
|
||||
private async appendAssetEvent(
|
||||
manager: EntityManager,
|
||||
visitId: string,
|
||||
@@ -1041,7 +998,7 @@ export class InspectionVisitsService {
|
||||
message: 'Indicá la fecha prevista antes de generar el checklist',
|
||||
});
|
||||
}
|
||||
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
|
||||
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId, visit.plannedStartAt);
|
||||
const generation = visit.checklistGeneration + 1;
|
||||
await manager.query(`
|
||||
INSERT INTO inspection_visit_checklist_items (
|
||||
@@ -1106,14 +1063,25 @@ export class InspectionVisitsService {
|
||||
FROM inspection_findings finding
|
||||
INNER JOIN assets asset ON asset.id = finding.asset_id
|
||||
WHERE asset.operational_area_id = $3
|
||||
AND asset.operator_company_id = $4
|
||||
AND finding.status <> 'VOIDED'
|
||||
AND (
|
||||
asset.id=$4::uuid
|
||||
OR EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id,parent_id FROM assets WHERE id=asset.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=$4::uuid LIMIT 1
|
||||
)
|
||||
)
|
||||
ORDER BY finding.created_at, finding.id
|
||||
`, [
|
||||
visit.id,
|
||||
generation,
|
||||
visit.operationalAreaId,
|
||||
visit.operatorCompanyId,
|
||||
visit.scopeAssetId ?? visit.operationalAreaId,
|
||||
visit.plannedStartAt.toISOString().slice(0, 10),
|
||||
]);
|
||||
|
||||
@@ -1295,7 +1263,12 @@ export class InspectionVisitsService {
|
||||
message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación',
|
||||
});
|
||||
}
|
||||
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
|
||||
await this.validatePlanningContext(
|
||||
manager,
|
||||
visit.operationalAreaId,
|
||||
visit.operatorCompanyId,
|
||||
visit.plannedStartAt,
|
||||
);
|
||||
if (!visit.scopeAssetId) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_SCOPE_REQUIRED',
|
||||
@@ -1350,13 +1323,10 @@ export class InspectionVisitsService {
|
||||
message: 'El equipo debe incluir al inspector responsable',
|
||||
});
|
||||
}
|
||||
// F6.1: Inventory membership is physical (Yacimiento scope).
|
||||
// Company is temporal Area context and must never be reconstructed from
|
||||
// assets.operator_company_id, which is only a creation/historical snapshot.
|
||||
await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId);
|
||||
await this.assertCurrentAssetsMatchContext(
|
||||
manager,
|
||||
visit.id,
|
||||
visit.operationalAreaId,
|
||||
visit.operatorCompanyId,
|
||||
);
|
||||
}
|
||||
|
||||
private async activeAssetIds(manager: EntityManager, visitId: string): Promise<string[]> {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default 'eNqtfUtz5MiR5l+B4bBWZVZdlQgAAUA3MAmS6M1X54Pq7hnZGCqJYqM3maDyUdZVMh103MMctPMLdJyDDmO6jc2Nf2w9gCQZAQQSHwo6yMSu9PgCCHeP8Fc4/mTepY/J7vCQbg9783f/8iczuzN/Z70zt/nDx11q/s5cJFtjmOw2+d7887vyZyb9PM8+J3fJ5yx5+dWWfh0df33629a4S43h8Uv+QuJIJONkk+zun/4nffnVrcw+Tz4l6eblZy79vDw+Hrf3yeEV2pNnT4xZ8tX88x/emev84THZZqn+HWfpYff0j02a743F8S55SHfZOtnSf0XbdHf/xVi8D9/r3346vFF/ld9+tYyMNz+s4skynIRG9L9mRji/jibLmP5r8X7+fvT+nfH68ySaX/9kxJPbaLEcE9WiAH5nXIf013I6D68jI5xcGuP4crGcR+HYWETz23gYPRMOp+NZ+PTXp38PjeXiovjHt9olX2RbI3p43KX7xJg+prvkLt8l2uW/SQ4HWov/Iy/E4r2eFcn2PtcumMyS23SbZxv1d1/6/afZlfpjILNpOFd/tAbyr5vj/pEYmVfgLZnT0fvx++i9+rvM0NkvebrNfjOuN/nHZGPM031+3K3TfQVS5vL1l2tjKt4p3X3O1umJsyep29LYfZZvl18eG2Qv+u1xkx+Sdfb0j61WxgqC3TMBoSa7NFGwShVOhA7n/1bFv82+rpP1L8RlRYFrY5iqOJ/ph2SraHVtiLwKF8lul2zX0iSOZoQsheH9MTEu6NXvFNk7P2ZzyB43pMjrLKVdZUP6/eW4leXx7HuF9EO2/+Mxk2WzNsJVRhxpFpkFxiTfHVJZeAGAxXH3CpJvZekGRhczGlpBERpwli9DwfvjIVHU4ewaD5NfxWvSnk17Pm39pAfy5m+x1vGbh+M6V5SlZcDTfyV3Ys7wIdllNNvrWKeFo8P0E23WW2lEmwgN090uNy5zmtP4/iiLuMWhoVfEjSMdDtKcHjRwnG/oqHsd5beN+oV4l/xyTLdCfl7H6YSGq+Po5Wh3N25oXtrcJcUftM6Z/XLM7vKC/YmxSDaZtELM+pbhytMz1hEiE+9gTNJ76YhirSI1MW7j2AhfRzitI767faVuFaJ8+2lDjFlnlbdrE6FoY4yT7ddEkljmtY+Zkcwp50PbtkNjFvl+mxL3XwcFLRNd5x/T3VZYA0b4hcyy182/TWy+T4QFt6HdgkwKYtwsS+8kftltckMHzgWdaq8DWMv7FabdJn+QhrTJxCjb3qW7XPd4bcIx2oijbZtKO5PdJiIjejhjSHxLaNbk1Xy1eduriYGl8I9l5bM9ZEZiwmX2OduLWaWn1cmLYirTVr/Md3di990b03QvHXF224YzFobkbbq7ex3jDFreUryaUe6L4YZOZDLA//Yq306bwMyS/WmH2BtketzR/6nnssNaEb48/fe28qqO3fKqpeTQsZiLs5ym/S7aH3bH9eFIm1S8PaS7h/ROcoacNtGa0QGfSi/uttPvD7nY2bcJyfPX15EcG0mnkFj6ymp5be9dDp7lX3PBtTVtEsZ0vc7uhJn4KtyO/y04u6yCEmCvMs9/lexNd9A6ansg+U4Kw/HmmG3Xssi5Vsujz9NPx/ssN+o+p9sma3MaVphyr0PsFsNmXsy0PX5JpEdsE6bTYRl+PW6UY8l1sYFiYSbpZ/nQcNvE6pbMw9IWF498vRNS+Tq6zb6+Te+T2iC/ZW1+TsiZl9YyAM0vYQsZkazvfNBuSMRkSKjawtt2p3mypS1tUx3WJiYX+SZV923eeqjRbj1Ldpvy51dl4A5uXJ6ekjzLT1m6udO7qaWRTiJCG51Z+qDPrmZahhKe/5M8tPzfSs+XXJ3yX3V+rVDHRN3DZWDWCVjvikpwdic4eaVW97SKKXlR3w7nNri9EqCjArLzgLzdJ5awXRXbPo/t1TRnlNGWt06+HdKvQQp7Pv92wEAPWGx63wyqRJNeQwUSHu/EdSXw9IJXfcaOmIrzS4KefU02QonCj8mveQ9cW3UefjimD+IY+HbA6i4ltDIlf1DsUt+O6tatgHDzKdn1eVIlilkchPS0m9oO0hHVa0YttHRMdmKfp/bP4Jc7+rdjB/oQlIzoqYiOFpHJcYd6SEzG81U8twXPqp6B+boqWR2fkGlM5UwYJkkPULstlpcrG3WgovOWw9TRHXonj+abzyrmalEr7OqIyZWc0HYjjv7vnrevHrheFyXoiO3roqiy5TNQ8bwWvECNseb1EKsMbnWyB+yBDrx09frAWrVz9tVG7QHLKlEL0t90I7zp0jrvgWwrLtSWyF6Dyj1gdfFtGY512hZst2ZYNz2n3cmCsXmNXycZm2R3FQHriOyp9sHwl2x7n/d7WL8S48zIIdmq20FXyEAPWd29OsI6AzXyOCJv4Pg16YOoZJ8Tsg3zXS885fQSG9/XY9qLO45Gk4qdtReoUwclAY3262STb3stgKtLCsl4TjcHkKssp0ekBc0qJ0BHTE8TCNtl6aHXg/pnc1sysNtpk3IUI3B7yNbiMNkYRZQ5POySzwSkGkdWxcz0W7ziwZncWg9Yqxm2bhx1BWdnwKu7TEdoW2OBJtu9CFn2gVWCdflmnWyNRSJCL2M6b3ohuw0pzrrpZVXs5qAFmTcEezX864jsNT1zb2S/OXUro/rd1jg4n9iVkbt5D3zQmvaVo3GDTtsxt1qTwsN0exBxmz6zMHSWRZ9Z7NZZavLedQosh95jAtWp+zU3hNM8yj+qIsSsbqi882N3nMBDJijshz6z+PVkOxkmVVekK2rFFv05qVgkHfG8gdZ2GuYirNYH1zqD25N/Xv20FHI3Tjeb7GtF9FinfdGTlfIy3X/KNoVXalwkX/I+uM4Z3LpIdASvVGiM812vZ+UVuU2yfbLthSjr2/WRzLtNHzRftaGH+aZiMHYFDCpeU5HT6wHoD85l1nvgWk159z6gmghlUQPTh+O+rauakvFsBO/Fa/BrNVUyltMp';
|
||||
@@ -0,0 +1 @@
|
||||
export default 'yOu7ZyquZFi3k+Hjy2pzcSQpmqebz8cHBZJ3iu35nrJrkCqmh0x1Yroi+pUA9zi/zw/pvg9kUIEsnYFemMGgWtKz/CXNeiFamjz6sM9SBtWyilO5QQ9EJaxPpxodDcNcmCfputeTOnXc8rzcHtd5r1V19TWIPRC5FrHuRnXF9fS4VZ3viFo18ZJ9NdTaFVHRpuxoDI9k1Gx7IFqDgbYKUATFH0m19r2wNScSHck/kHYlvXCZ1nauO0Rex1S83VDMKmP6nRx0a1C5SUBCVYlgs4oLLVI85yHrIZCb5CFZ61zCztj1YP7NMdkeN/1QvQbUmoZ1Rq7oGHkQa3Iw+2EGWsx/wuoqxR768uW6ud95Eqt9kr7LbjFATOxBV1RZ/35K1tmDqNEUx9zNwki3xjA1LtJt+ilb04H6/XDWb7JKtbu29qY7qqt/hYjM3OINisjQP/EtuDrfe4M4fqwzvJj7Iu0/n9dZuLrP4evXkI6OcPOY7O6FE/ZPeyHVvXt4fC4q7wPKBpprBjKg1e0QUStPNsI0qduTnUGZ7nQmCz29r1QxdgXW5RSOT/+Vf+2F6lRu6F6JIpFsrxq/nVH19ztkRNYRkVcs/5t8WzEhOhbfeW0Vgl0BfU2J7WWlxLYrZuXMvCC/kWzdbQ9IpbzktTpQcwx3xLVU3Hm+T3oxSKksEXppXCS/ijUti4L6INtqaGybrHNNQrwrauUS1O3p1m0PRFeLaPyrGf6r2QeX63GLY7QSJuyI7OmRa9ZXR1hfD9tbZgM9btWO61gtqx5Te9qqdg/ZfZ+SeEspMqnffpM3VhsJzEnI7ByypvKuK759Fr9q1XRFb7jaJ0M63U4axz1z80/G7VaDYTl1H/B0Wy5R9nO7WuDbKhvemRuHvYD9usWxEYGG9OOuEv3vjh1UHzpSbjvK2F43mXAHLZchZWy/GxOVghTprqQMGXQsg2f6q5SisFwrIM6gm0ArxSjiygqdoeEu3dIBrcBanbINllKMIhUVFdd/hPNyqIblOs+gRGSOu11mDCv2VGfISqgz3CQPtYBkZ9DKqVcubqXArDOof/aKbC/oALl922MCrs1vn1a7F7Baa/nluK2rtMM6pZAspfKkdidYQba7BVS5Xb8cLMM53fYe7oAZTsftuKqa4q9kk2zqN7scjjyyhMxrN4fIYd33gvTaEsedEX0dYs1m7QwbtFzY7oGtKyCpOzCdUS38lrg8jddxGnZ+GgXb76bMSi2J0qpFBg26HaCeRvHCu/xj2gvUbboE3weUV+Mue8JS00LuoCO7PG09ulHcHMizPx7TXuj+2Vv9quXTGTyoXtaN9kR7l6nGq1u9+Mza7usN0COjO7RVb1PQD5Bpe+z1grR1kPWTuDuwU7tjVweFAoevp6ZSeSJ3YpAx7Y73NfmZRg0ybscTXqk9qfdxkJE7Op9KDUqhCeKe8S6rSGu3onHLb7xXIIzIz1n1JlxX/EB7FW66SQ6V+svOwPV7Bc83GLNDP+R6Cq8MH/RDrR9rel+5M7BzBrgmzl3B63YlHRrXSc8VrsdRZsnukPVcYdWHuz9uS9WbZfmv/YD9aqAj3KieYWfEQIdYs1c7wrJ6yUoB208G2MCqoWrvG3XGZVXPgiShem28M6hdAR0mn5OekI4Wsj+33Erf3U15++6QbLP9Jsl7YXMNtqbLQWdcr5YUnhF65QJuZ1S/tSawM2SgheyrC0qdStEtSSzrKO+1hTOlMEXuwSRjVlw0y24DZZpk5mhDZ/rXatywY9cgy67U14oU6dfKBc+OkE597y6cn+xzryd1K3mc70Wrg30fRLUHWdm1eSRKL/qAehVhXaTrl7vi4upHskl1l+06zuLXm3bJAtYtjs6U+pBaF4na7tix3QMbnO0YJj930E3ZlJIRpaGY3KFl0LFdCWvsNyajWh1RbW07MhmRdUQ8161MxrU74rrnmpnJwE5HYK7rela0t9uuRQ/Wb9cHVtW62yz9tU+Klyn1I1InyuHu+LUh1dtxgqChlfi3r4JSTHKbiFYt42O6O3zTQvzhnZltyYDZCP8u314lD9mmqcv/RbiM5nGobbI+Ww61Lfxny1DbQn81iS/DS2MUDpfaxvnLeThZzKbzZaRtlj+T/l0xcKTHU4Jcy2ttR/x5NJsu4uV0Hk8bmuJPf54u9K3wh+HoMpqH+j748+jSGE4n9BpGPBlGk8tYhlFWaBROlqFxGRkRkUfXoaltAfZKNpuO4nE0F3jEvf3xYxsDy9+LZkm17vbD6Sga0vvLTD1HvwwnP6wimdXt1OVCTEeyIACjrsUXHcKRLB7AqNlqNH76j4ksNcCoaBTdhpdTWabOjVpEs3BO9HPjIr4KF/FwatyQDP1M7yk9sN8Z5DaaL+OhBBGgEMv5CUMR43NjL6bji9AYCqGLr1ay2FnIwIubpSL57SMuowUJ8c/hOKY5p0ahePHt1HhzuZqNoh/fKurRA46keRKPRtO3iiK1AxJkHF7Nw+txqHTD77KIxng1WsbRMpyFC6UvPjL9LBzRyIUxpZ3gmva/8PtoofTIP6vI4fw6pP1o+oEW5uU/lGb554bPYjF3sQvR3kKqN4zlN2iRw/GUFl0o0ZDWgeT4Tam7JJhvldb57Rih+ArLxWqxjKcTPUzbhkaLSFp4KW9pLeJZ3WiYRv5Y8x7IHIScjrpb9alcaNiQzkB1HIfGLf53PCZOKl3ygWFCq8LhzVTplA89prrFswAaVl16ewANq27ytgUNq+7yNjs/7GV7VZrknxtx9fup0hX/HK1GUu0WmbiZLo1pPFJ64Z+jL3YWpQH++Qc6u4XYLZLwoqxK4/vzE64uQ2HMLKVpnBYROO1U4SxWut2fZf88XJanZDQfy2ekoxEAu3H9HPs8dYPmOQ40rKoKjnt+2DAaXdJhQTv21Wi6DIe0YSq97M+NHcWTqBxLenu5GorB4r/C61VpYs6np39VOtyfg9QdIEpb+3ODr+LRci4ZyE7QsmTT+bzYq6Y/xjSV4G2o';
|
||||
@@ -0,0 +1 @@
|
||||
export default '9LE/N3gcXcY0ICrfvxBBpYl9dWyr26IRIler59Jx7trnB0ULMYKecGSQhS64sZwqfezPDSZX4ediTcQbLobzcCaJo+ueHzyPhtFsKQ9WRvOWl52vxAfV6BSJSABG8ht750eSzC2jUhKH4XJ6SXaw0tG+OpbXhG+hbApucH5M9MMqJqPRKDf5YUznSfHGT/8xIvFYKC3uz+G87pqGvG2WwjWm15HWgFvnsYr9ZqE0vD9LTg65TG6fJ6/YLVwjRV6NXIJ3z9OrmyXn56kX9OyjeBkJc/cyXpDdeLGKVR3h3nmIcLiKCsUwPhjFjmbKHYnOjezEM40c+TojQRrjDVrGjFbXQsqHN5Jqedb5QeLzhDSJOhFrGzO5jCYL5VT27PNjwtE4JI/m2bE6HRIliin3+TkLQssZTa7mcXVl3PPjTkJXHEan5/heCvXwltGnc4FQ4nIHFAymnWX28g+m3KvnHNY8uiBTS+aPf35ARXm9FrnRHgt+i+BEP96EK/ExS3lMi9wsSLuicWhMotU4XMrmj8+Q5bwe0ZiR8Sa8INm7pWeOXr0xv0WWbsgXl+ZrExqSt/nwRgrh6dzvQWXNo6kxjoZPf5kIzzOML0y5UU230XQEhVej6EdTbkyDQJSu79RYrITxE1+MXmXW91sghuFtYVSQmF4TX+Pb0HgzG86kRQ5aEBark8FlTMLlSjYig0HL0HjyUxH/k+QpsFrG0Bobb17XLhRL9/q0Aeu0ZJoVC+wWBFod40192aRncKAVk4LIgU7SzkV7A51wKe5QOJqRLTMfvpokgdcyprKFBH4LPYHX/IZAJy12sxlQtE05O0DjqBb9UM4OUq2Bos9JyyQ651N7IhfNTc6CnXVGij4mteHq15xEGvcuNR7zzdPfH+QPChUdS84ODu8zUXm2S6VPfg54y6CrbHPYlXNuEmOfb47lt1IfkrtdairNR87iXNAf4vsY+T77lK2L7xIXoMmp+vAuOyGbStsRCDM1su2XdF0+mMAUlUoiw1TBC1rwRElLsT4CZJ0c75INId9ln7O7Y5E2f8x39L+veTnfId+dvq1ENNt7fWqqMP+eVYHMQXJZyMnTp6pK2stTzCqe6ugV53QVnZyh4nPRUqZIfqvJ1JgSSljEoEUE15ifHAvJiVa6iNKPE/Lgv5uNQjp1pvMx/S1En2wO8a/xQmiAbN7LAl4+r5imos7Kx6LD0e1qRH7R7exWmxajs3ZWvFp8KT6rfUU+l/K8gaqgZLErCUB1S42e/hqOyMoqXLjJFQG/mdMmIMfh1Q316a8CzXhz8nU/xBPlKd42fGF6JeiN0FjG0ZwYQwcBDRInwQdDuPaVocqeNI9FyORlQuNiNaKdhR7irT7pJhyTUGweJ1t4HE/i8dTUfk2H/Ob41V5eij2H1mEcjS9EKN/Ufi1HP4aMpnF8LbHBUt2sYcGyUTyexdHPoan9og1tmiRfxnN6rxhxOVetaaUeZDqnxTd+qsMqdR0niSJ9uZr+3tR+TeZF6EQcKRbWeFVEldKL63l8JbzwkJ5YcFaWeKWaorA8p6b24y0nhtYC+a5Wfktdvl4Jabg0td9YqQiZqf1gioZ7clBaqS0gJS/ZJnBHIsJgaj9scvv0l3KFtc+opP7H4WRahE4XpvZ7I8+8Enwgc6f444o8OTX+La3haesxtR8DUZeYLNRyB1q8NbUf+dCvjfEdneaTU5rru1JGFzrRtBs4R1sE7Q60x4am9psdJ19ncQpingJKH65X4fxSaAP5M2o8vOYlSas1MsoYkan94oZwiovNJhJxp8jUf0NjL2yB4rRcr9O99IFBpWFBTEdbcVrTAb2TjtjPZbegN+Nke5+8NfVfv8j25b3ltahpK0rI31zvks+JWOy0KBSiv6ZkXrw1tZ+7GD797SE52QlkbWyTX9XvuaoXphIjMQ7lLYk363yb/lY8a7p5+s/1YUcv8dbUfqxinByST8f0nmycN8Nke8jukjt6rGX2KD+WzPV4c3zItpXPtauflXj6+zZNynW6S0ztZyJq+69yv35Moihtdcr9+GE0J4+QxLzIT8gx4oE+aimOISO8jUzthxSicbw4Rdmvw4UcF1UOt116yNSvnL789vnpb5vPR2EepvfHnVhAU/uNgot8XSzKTfkP+q8NJI+H5EXM7uWvwMorfFmKQyEan0Tvn3dGWkr0FxK3koum9tMA8VYUU5VlasWnlMjoU1mp3LWukpNkkcBuiLF7sglPk5rapv7CyDLm4U+VkK28TZZ7321xhpvaFvuach0yD6Ifl2ITU2KSTBekN3QJFVPbAH+Zkol7fEx3n0XTtC3Nss/2Bu29kvWpXDom8/NahEjnkZwCUK4PL9PtvjCo062x/iWjZSzVIt2vk8f0xGHJH1FuCFdtnHkRu6c/QmMqDpdyE3z6f2pctmqJmtpm8KdTwNT2dCdzekYzPv37pPTyRtFVOCyPiXJhTW3r9memFGZrrbTKU4ub5vPpRUjmz/zFNDTeCaOIbCgCCIu3FOeoqe24LiVhqm+i3HmdPf3f8jA0tW3Qy/qON2Rjj43Sw39r6luaV8/YZ9wPxvU8HM+UgYq58vSfO3HBcv/OOG5JqkgUPhY7hPgX8iZPfz3vIPTnY3YgZ+q9qe1cHov7xoUDqaqsp4YVST/EoUneDjk/5d+mttW4bFvK+62v42slslmxaE1tN++hqJsSfsPlKWIwii9K14u0Xg30qA27hYAsFKtNuQ55WnVT21FbdXiEkKhZOuXCYoW1Lx7nwtT2wH6xkj4Yw6e/FGaSqW1tXZipJQMWUqmlekkwXoykBNcqGk1NbQPqofhQ7ybdPf09Megk+iykgOyID8aiduIEddYsTG3HaGJqdF2YsfVQlYYXBcsWK5lKwxF6JvIH5OVT7r09K06RRhhGsRzo402p4El8Szx8c0FeOR36o5GsbcpNNbJmF8+DFqvvI9XsV+6eSTY4icotnSpvSkURG+5bU9sM+eR7VDwPtbvxbVYelR/W';
|
||||
@@ -0,0 +1 @@
|
||||
export default 'yUcRMjmcTgBT37B4nq7FkVMEZE7HK6n4vvgCjr4V8Szb5Ifc1PcTFi270pwOLjpu6N+Oe1F5muxMfafg8ujPny0NQ4hVKh/nahfgeXqf7Q9lv6qH01BT39T3hjyp8OdrubJWbc9byLPxup0VFnZaBpfEWZk8iDut+g68bYM3+TqpPFvlq8rr3KBFp+NYNHgzYnrr3ZaM8+RjsiMLLXkko/tQvOZHGpTmcpxkcA4p+k1F+ngy+Ion+2KkfzwqVrXaRfciv0vvE7Oh/W0RjSvmkZZFjdPmRVV5viPz6LdfEuJ9LjFeDaOK76eLZTP1PWWX4tYFWSmb8qa1vhHs8MWrKaKFD8XpZuqbuJLQpofSkSmETMb01faDu7tkXX75paF96lEAFKu6y++Oa5XT6ud4t+sNGcmFwVUsvfjrIMBNfdfTS+E9PaR6IVKCIxfp7qHoD/tQN4LV3qQL0pmUaBfrRHKF1E6jRSBXPFz8Esc19d1Db8WXzU7y/okc0vK7Eaa+Mehyl2z3n/Ldg8pqJVRyVQSLD8mj8DjWGfmBxdu/OHCiCWK6zsnl28rT+DobR4x8sWSKiPkJRrjOB3l4oAgbbZS70nH5uFH3HzWsktCfQgHUdTdy45N4CVPffHO2yw+S4/4oNo+98fD0j88kk5K8KiGX2+wUnS9iA0J1C4Pd1DfNvAkv4lG8PJ3kM7KAp/PrcBIvxlNjSsZPLEzGy+lwNRbZmoJKjoM6akRlEi2NZ0Q6octKo1lZwHcORi3ZWUVk2d7El/Onv6xGZH+9QNLZq31CU98QMxyF83FoLKYTOUWvtrY80VSsA7VP5WI6G52MwPORNLUN5YvBeLKRWsYqsZtT5eurBXGqozP1fSQlclHNoZgOal9I4Z4sjEk0jBbhXLk+okZlnmN7ixWxT6yyFOFVYjANUUC1+aLk8wxX4eUpBXeqyZaztGpvRe2wyjLowm2Lm9XSuJz+Xl4EX2cIm/ouhvPolqaOX2tVRHA6JHdezhcqERt9TL+8fFMOJU+lkr1UIjkNAHq/WWabEum5nNJiGqVMm/q2gfpgpqlvBni1Em7OQkRIo5lwisv/pKFlvt3Ud/ir+CU1r0Rt3hcOSb9KVBFWWSnXn5T4TyHbhVtUSKTYYcgFJOmYrYo1K95qcRXekr6LhXgjNpGw2EPGRYmJmIN4Yep785FxPJ5NS5rFyWAumS/49zqpqe+/95wyemesLopsE2nhO/IUSNVisX8JZ+Un4zW8M55O4sI1NaJitxM7pny/Y9C8okJ7Th5cGf6frWSGK1GoS/J/nrNfND/NF81vQ+0wpeg8Gt6UCU2RNqeBs5tiTN2hVrvjdV0FLaBi8MVj2jSHQvi1wRm16d1pF9TeN1EiVGFxnWdRcvtqTg7981b9kv581hF6dEnSlUBVnS+FtLwssbpZcXUTmtGEYjlKgZOHGG9eZZF2hhlJymntpFNTiXsVFe/PR+sLTPTjcLRayIkNteHcyQqRbZ8PvxZ34z9sMjJ/JBtDiYE9JNunf7zYM4/kcDw8/U1YHftPyed8J2whYXhsjT0Z/sVN3cIdkewcJSRGhmAqenkRTLI9JhtT3wVOBGXnalBW7ej2vP8335FUImNSduaUKpIpz2TuXrKPCyHK1R3Z8/Rpppk4ViYizWR8oGcrkvNLlTeKfIzCZ8U9sVTsfpcrZSpZBC7i6Sw+5UjL56SdZTwTyTwyX2RFULuexZWjUAmQSWv0mkzW3hGVKyleflPuRv0iaL/e569XjwE61kDHKnQ2SOeAdC5Ix0E6D6TzQboApLMGKKGFEqIssVCeWChTLJQrFsoWC+WLhTLGQjnDUM4wlDMM5Qxr4owNapUNapUNapUNapUNapUNapUNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapWNapUDapUDapUDapUDapUDapUDapUDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapWDapULapULapULapULapULapULapULapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLapWLahUHtYqDWsVBreKgVnFQqzioVRzUKo5qFUe1iqNaxVGt4qhWcVSrOKpVHNUqjmoVR7WKo1rFUa3iqFZxVKs89GWqhMxBCV2UkKOEHkroo4ToW9sDlNBCCZtY6KOc8VHO+ChnfJQzPsoZH+WMj3LGRznjo5zxUc4EKGcClDMBypkA5UyAciZAOROgnAlQzgQoZwKUM6KSCGNNjdK2YUoHpnRhSg5TejBlEyctC14lC14lC14lC14lC14lC14lC14lOH5ao2xeJQavEoNXicGrxOBVYvAqwfGwGmXzKtnwKtnwKtnwKtnwKtnwKsHxjRpl8yo58Co58Co58Co58Co58CrB/mqNsnmVXHiVXHiVXHiVXHiVXHiVYP+jRtm8ShxeJQ6vEodXicOrxOFVgh2bGqUNUzoDmNKCKRlMacOUDkzpwpSN3Kza0Y4HU/owZYBSuo08CtCghAVb3TVKtzFDOoBTzQN09hpl8+wWmrS34Mkt1JGoUboMprRhSgemdGFK+N1dD6ZsXKVa3UeAUvIBTGnBlI08QjPzDE3NMzQ3z9DkPEOz8wxNzzM0P8/gBD2DM/QMTtEzOEfP4CQ9g7P0DE7TMzhPz+BEPYM9Ewan6hmcq2dwsp7B2XqGpusZmq9naMKeoRl7hqbsGZqzZ2jSnsFZewan7Rmct2dw4p7BmXsGp+4ZnLtncPKewdl7Bru3DM7fMziBz+AMPoNT+AzN4TM0ic/QLD5D0/gMzeMzNJHP0Ew+g1P5DM7lMziZz+BsPoPT+QzO5zM4oc/gjD6DU/oMjpEwOKnP4Kw+g9P6DM7rMzSxz9DMPkNT+wzN7TM0uc/Q7D5D0/sMzu8zOMHP4Aw/g1P8DM7xMzjJz+AsP4PT/AzO8zM40MbgTD+DU/0MzvUzONnP';
|
||||
@@ -0,0 +1 @@
|
||||
export default 'PFTVPFTVPFTVPFTVPFTVPFTVPFTVPFjVPFjVPFjVPFjVPFjVPFjVPFjVPFjVPFjV4GhtjZLBPGIwjxjMo2ZV81FV81FV81FV81FV81FV81FV81FV82FV82FV82FV82FV82FV82FV82FV82FV82FVg+uKapQM5hGDecRgHjWrWoCqWoCqWoCqWoCqWoCqWoCqWoCqWgCrWgCrWgCrWgCrGpyPqVPC/LFgBlkwhyyYRRbMIwbziME8YjCPmm/hDdBreAP0Ht4AvYg3QG/iDdCreAP0Lt4AvYw3gG/jDeDreAP4Pt4AvpAHJx/rlDB/LJhBFswhC2aRBfOIwTxiMI8YzKNmVbNQVbNQVbNQVbNQVbNQVbNQVUMT0zXCZlWzYFWzYFWzYFWzYFWzYFWzYFWzYFWzYFWDq0drlAzmEYN5xGAeNasaXOlZo2ys6K5TujAlhyk9mNKHKeF3b6zrrlNaMGUjN+EL8Ta8nDa8nDa8nHCRa43ShSl5IyWaKq0RNi+SAy+SAy8SXONao3RhSnyRuANTujBl43qiKbYaIYMhGYzZWD5Zo+QeTIljws/pNW41cIzehothbbgY1oaLYW24GNaGi2FtuBjWhsOrNlwMa8PFsDZcDGvDxbA2XAxrw8WwNlwMa8PFsDYcP7ThS3c1ysYyTxsuXK1Reo3tElBn2IFdshqlA1M2VvfWKD0GU9owZWPLDdSNceD6Wgeur3Xg+loHrq914PpaB66vdeD6Wgeur3Xg+loHNvod+HqXA1/vcuDrXQ58vcuBr3c58PUuB63uddDqXget7nXQ6l4Hre510OpeB3U2HLi614Grex24uteBq3sduLrXgat7Hbi614Grex24uteBq3sduLrXgat7Hbi618F7ccHNuOBuXHA7LrgfF9yQC+7IBbfkwnty4U258K5ceFsuvC8X3pgL78yFt+bCe3Phzbnw7lx4ey68Pxdc3eu4aHDOgb3wOqWHUjbbE7Wyw8bn9FF/qEbpcZiy8Y1gP8OtmdCNvatQE7pG6MGU/gCmtGDKxkZXaKNcFw4Iu3CYt0bpwo/Z6BPUKR2Y0oUp4Xdv9AnqlI2rZKPh/Rplo9NYo/RxSnh2v3E9a+cwhykb19OFMV0Yk8OYcCOqGqXfyHd4/+JwUKNGacOUjZG0OqUFUzKY0oYpHZjShSkbVx5OO3K4aQ2Hm9ZwuGkNh5vWcLhpDYeb1nA4qlGj9GHKoFE+0WgBR6MFHI0WcDRawNFoAUejBRyNFnA4WsDhaAGHowUcjhZwOFrA4WgBh6MFHI4WcDhawOFoAYejBRyOFnA4WsDhaAFHowUcjRZwNFrA0WgBR6MFHI0WcDRawOFoAYejBRyOFnA4WsDhaAGHowUcjhZwOFrA4WgBh6MFHI4WcDhawOFoAYejBRy+cslhz57Dbak43JaKw22pONyWisMVA5yjHlGNMmjkpgdj1u54MZgSx2zkEXynokZpw5TNXoEPewU+7BXA8aQ6JbxKzV6BD3sFcIk9h5uxcrgbK8f9RrgfK4cbsnK4IyuHW7JyuCcrh5uyerDPXKNsbjI9gLtMw+2z6pQeTOnDlPC7N7eaHsC9pgcwj+A6hBpl0LjycBzTgysxPTjuVqMM4NmDxtnhskkP7R/iof1DPLR/iIf2D/HQ/iEe2j/EQ4sbPbh/iAf3D/Hg/iEe3D/Eg/uHeHD/EA/uH+LB/UM8uH+IBxuzHtw/xIP7h3hw/xAP7h/icVgpYTOtRtloyNcpHZjShSnhN2o05OuUjRKCXiz1YOvHg22aGmVjnqpOacOUDkzpwpTwuzfmqeqUjR9JqJ3BjZTwGVyjbKz49mvnZeM3Ghy0sNOvx2sGOKmFkzKc1MZJHZy08Zsb9SOR46QeTtooKhx/AI4/AMcfwMMfwMMfwMMfwEcTHX79Tn3j1zDqN30bv4dRv6nY+EWM+kdyG79WUs8lNEl2AMfuAtgSrmM22lAaUhcn7fAAHk7q46TwWlmNlpSG1MJJGU6KSwDDucVwbnUQF4Zzi+HcYji3bJxbNs6t5q8L1b0TGyd1cFIXJ+U4qYeT+jhpAJM2hkk1pBZOinPLwbnl4NxycG45OLcaW/MH8L26AL4DV6NsXlSORorrz9n4DQENKfxOVuM9Iw2phZMynLRRquC7fQH8wb0apQ3P3rwBebCceKj7W6f0YEofXs/mTb3eXc/BSRtfv95IjOOkHk7q46QBTNr47QENqYWTMpzUxkkdnBTnFse5xXFucZxbHOeWh3PLw7nVeNUyQDu1BWintgDt1BagndoCtFNbgHZqC9CAWgB3agvgTm0B3KktgDu1BXCntgDu1BbAndoCuFNbAHdqC/BPesKd2gK4U1sAd2oL4E5t1gC9p12nbP680QBOfNZJXYaT4m/lOjipi5PiK+B6OGnzYuFf5Bzgn+Qc4N/kHOAf5RzgX+Uc4J/lHODf5RzgH+askdo4afPH3eqkFk7KcFIbJ3VwUhcnbWasDW8u+PdBa6R8gKN6dgdapwNt83I5+OO6HR6Xd3hc/COHddIzb+bjsH4H2AAWmvoRz//8h3dmSbhJDkR5lW3vsu393vzdv/zJrCFoX6CNqrwO2IpVXoOsNVGFpmTQlAyb0oamtKEpbWxKB5rSgaZ0sCldaEoXmtLFpuTQlByakmNTetCUHjSlh03pQ1P60JQ+NmUATamvv2yjciAs/eMH3/j45e3ldjLo0Szw2cq7yK2ff2/Y8wbYpjfAVsQCd1pwqwX3WgvbbC1st7XA7dbC9lsL23AtcMe1sC3XwvZcS7fp/uHP/x8ocJi7';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { inflateSync } from 'node:zlib';
|
||||
import payload1 from './authoritative-inventory-model.payload-1';
|
||||
import payload2 from './authoritative-inventory-model.payload-2';
|
||||
import payload3 from './authoritative-inventory-model.payload-3';
|
||||
import payload4 from './authoritative-inventory-model.payload-4';
|
||||
import payload5 from './authoritative-inventory-model.payload-5';
|
||||
|
||||
export const AUTHORITATIVE_INVENTORY_MODEL_SOURCES = {
|
||||
territory: {
|
||||
file: 'modelo_yacimientos.sql',
|
||||
sha256: '6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a',
|
||||
},
|
||||
inventory: {
|
||||
file: 'modelo_relacional instalaciones y sub.sql',
|
||||
sha256: 'fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type AuthoritativeInventoryModel = {
|
||||
departments: Array<{ id: number; nombre: string }>;
|
||||
companies: Array<{ id: number; nombre: string }>;
|
||||
concessionTypes: Array<{ id: number; nombre: string }>;
|
||||
areas: Array<{ id: number; departamento_id: number; nombre: string }>;
|
||||
fields: Array<{
|
||||
id: number;
|
||||
nombre: string;
|
||||
area_id: number;
|
||||
empresa_id: number;
|
||||
tipo_concesion_id: number;
|
||||
}>;
|
||||
installationFamilies: Array<{ id: number; nombre: string }>;
|
||||
subinstallationFamilies: Array<{ id: number; instalacion_id: number; nombre: string }>;
|
||||
findings: Array<{ id: number; nombre: string }>;
|
||||
subinstallationFindings: Array<{ subinstalacion_id: number; hallazgo_id: number }>;
|
||||
installationFindings: Array<{ instalacion_id: number; hallazgo_id: number }>;
|
||||
};
|
||||
|
||||
const payload = payload1 + payload2 + payload3 + payload4 + payload5;
|
||||
|
||||
export const AUTHORITATIVE_INVENTORY_MODEL: AuthoritativeInventoryModel =
|
||||
JSON.parse(inflateSync(Buffer.from(payload, 'base64')).toString('utf8')) as AuthoritativeInventoryModel;
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user