Compare commits

..
Author SHA1 Message Date
admin a5d8022720 fix(f5): make catalog SQL text parameter types explicit 2026-09-08 23:12:47 -03:00
admin bfa9110c77 fix(f5): type relation backup markers explicitly 2026-09-08 23:07:25 -03:00
admin 915a2ee299 ci(f5): include canonical hierarchy migration in rollback gate 2026-09-08 23:02:37 -03:00
admin 91884b567e fix(f5): guarantee canonical inventory hierarchy before territory preload 2026-09-08 23:02:08 -03:00
admin 9dad6888b4 fix(f5): make territory SQL parameter types explicit 2026-09-08 22:56:13 -03:00
admin 1afef0f1f3 ci(f5): bypass irreversible historical reset in clean rehearsal 2026-09-08 22:48:08 -03:00
admin 3cfd3090aa ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal 2026-09-08 22:44:30 -03:00
admin 8e2ed6e38a test(f5): model normalized yacimiento collision explicitly 2026-09-08 22:38:40 -03:00
admin e92b1623c8 test(f5): distinguish literal and normalized yacimiento counts 2026-09-08 22:35:53 -03:00
admin 78e1d5cbf6 test(f5): make merge compatibility area-based 2026-09-08 22:35:26 -03:00
admin bd110b22f3 test(f5): align operator lifecycle with area-owned inventory 2026-09-08 22:35:15 -03:00
admin a53306b9d7 test(f5): align operational navigation contract 2026-09-08 22:35:05 -03:00
admin ec535233fa ci: prove F5 migrations revert and reapply cleanly 2026-09-08 22:08:54 -03:00
admin 35fa968128 ci: rehearse all migrations on clean PostGIS before merge 2026-09-08 22:08:08 -03:00
admin ef781abaca test(dashboard): route F5 summary query mocks explicitly 2026-09-08 22:05:58 -03:00
admin 3752b78f58 test(dashboard): expect inspector activity and act follow-up metrics 2026-09-08 22:02:39 -03:00
admin 923258f5bc chore(release): expose F5 WEB phase 2026-09-08 22:00:30 -03:00
admin 00065d55a6 chore(release): align WEB package with F5 inventory cut 2026-09-08 22:00:21 -03:00
admin e92ff01981 test(release): expect F5 health metadata 2026-09-08 22:00:09 -03:00
admin 7a66d67296 chore(release): bump API for F5 inventory model 2026-09-08 21:58:20 -03:00
admin 99ea1614ff chore(release): identify F5 inventory model 2026-09-08 21:58:07 -03:00
admin 8a9f81b8de fix(web): redefine inventory configuration around hierarchy classifications and columns 2026-09-08 21:56:30 -03:00
admin 741b2f2d8e fix(web): configure finding applicability by inventory classification 2026-09-08 21:54:29 -03:00
admin afa11918a6 feat(web): add inventory classification administration API 2026-09-08 21:53:59 -03:00
admin e2bb122d9e feat(inventory): expose family classification administration 2026-09-08 21:53:31 -03:00
admin 21c51bf672 feat(inventory): administer installation and subinstallation classifications 2026-09-08 21:53:17 -03:00
admin 90a85a7c1f feat(inventory): add family administration DTOs 2026-09-08 21:52:25 -03:00
admin fde562e2b4 test(inventory): lock F5 authoritative model and merge invariants 2026-09-08 21:51:04 -03:00
admin 5e26b4a899 fix(inventory): merge by physical area and preserve sealed documents 2026-09-08 21:50:21 -03:00
admin b4d81cff68 feat(web): create companies independently from physical inventory hierarchy 2026-09-08 21:47:12 -03:00
admin 53c7ca45f1 feat(web): expose independent company master in inventory setup 2026-09-08 21:46:29 -03:00
admin 0e320a9395 fix(inventory): make guided creation area-owned and support companies 2026-09-08 21:46:12 -03:00
admin 791137eb65 feat(inventory): allow independent company master creation 2026-09-08 21:45:23 -03:00
admin bb4a4eeb17 fix(inventory): make authoritative territory preload safely reversible 2026-09-08 21:40:41 -03:00
admin 39ca9dc35f fix(inventory): harden authoritative catalog migration checks 2026-09-08 21:38:00 -03:00
admin c651ef9616 fix(inventory): preserve custom catalog and apply authoritative universal findings 2026-09-08 21:34:52 -03:00
admin a3dd705190 fix(inventory): resolve field inventory by area hierarchy, not company ownership 2026-09-08 21:31:37 -03:00
admin f6564194b6 fix(inventory): treat company on asset as non-owning creation snapshot 2026-09-08 21:30:21 -03:00
admin dcb6b9fdce fix(inventory): apply area-owned operational guard on F5 up 2026-09-08 21:26:07 -03:00
admin 4e93b29edf fix(web): load F5 flow styles 2026-09-08 20:06:14 -03:00
admin ccc5226f72 fix(web): style act follow-up tabs and F5 inventory context 2026-09-08 20:06:05 -03:00
admin 4bc54f67fe fix(web): show only real inventory instances 2026-09-08 20:05:04 -03:00
admin 3f0148e4b7 refactor(web): make inventory hierarchy area-owned 2026-09-08 20:04:35 -03:00
admin 1a6f6f5c68 feat(web): add real inventory list client 2026-09-08 20:03:32 -03:00
admin 06aaacfd3f feat(inventory): expose real inventory list 2026-09-08 20:03:13 -03:00
admin 0548115516 feat(inventory): add real inventory list endpoint logic 2026-09-08 20:02:59 -03:00
admin 7945d1a729 feat(inventory): extend inventory browser filters 2026-09-08 20:02:24 -03:00
admin 8ea8a35829 feat(web): add inventory browser API client 2026-09-08 20:01:55 -03:00
admin 31a3a207d6 fix(inventory): make operator relation temporal and non-owning 2026-09-08 20:01:17 -03:00
admin bd6d91d7ac refactor(inventory): remove function catalog and add inventory browser 2026-09-08 20:00:09 -03:00
admin 598c987031 feat(inventory): expose area-owned inventory browser 2026-09-08 19:59:50 -03:00
admin 84217b67d8 feat(inventory): add area-owned inventory browser 2026-09-08 19:59:39 -03:00
admin 9e29c9d69c feat(inventory): add inventory browser query contract 2026-09-08 19:59:18 -03:00
admin 4bbe32ba93 feat(inventory): add inventory-only tree filter 2026-09-08 19:58:18 -03:00
admin ff518ee916 feat(inventory): add inventory-only asset filter 2026-09-08 19:57:58 -03:00
admin f4cd931d51 fix(inventory): preserve non-hierarchical operator snapshot compatibility 2026-09-08 19:57:18 -03:00
admin 436b57a3da fix(findings): use only authoritative F5 family catalog 2026-09-08 19:55:04 -03:00
admin 60acadd24e feat(inventory): preload authoritative technical catalog 2026-09-08 19:54:44 -03:00
admin 218f792f13 feat(inventory): preload authoritative territory model 2026-09-08 19:54:00 -03:00
admin 91e346d4c4 feat(inventory): add authoritative F5 source snapshot 2026-09-08 19:46:59 -03:00
admin afbece7d70 fix(inventory): count only installation and subinstallation instances 2026-09-08 19:07:50 -03:00
admin 88d43b7414 feat(inventory): classify future concrete instances at database level 2026-09-08 18:23:16 -03:00
admin 6b5b522be1 fix(inventory): keep field yacimientos structural 2026-09-08 18:22:47 -03:00
admin 637e9167b4 feat(inventory): map physical instance flag on asset entity 2026-09-08 18:22:15 -03:00
admin e082f5d062 feat(inventory): mark field-created records as real instances 2026-09-08 18:08:19 -03:00
admin ead1beb9d7 fix(web): clarify dashboard act and report concepts 2026-09-08 18:07:55 -03:00
admin 8389991b2c fix(web): align dashboard summary contract 2026-09-08 18:07:26 -03:00
admin 79258836b1 fix(dashboard): align inventory and act follow-up metrics 2026-09-08 18:07:17 -03:00
admin 11052c83aa feat(inventory): distinguish physical instances from structural records 2026-09-08 18:06:11 -03:00
admin a27f17d642 fix(web): remove redundant inspections lifecycle legend 2026-09-08 18:05:44 -03:00
admin 9083883bf2 fix(web): remove legacy imports and function catalog routes 2026-09-08 18:05:16 -03:00
admin fffd1ddcc2 fix(web): simplify inventory administration menu 2026-09-08 18:04:55 -03:00
280 changed files with 3004 additions and 22668 deletions
+17 -106
View File
@@ -1,36 +1,31 @@
name: Android CI / RC
# F6.8 offline/document barrier: lint + real tests + debug artifact + release compile.
name: Android APK
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
on:
push:
branches:
- 'main'
- 'release/f6-1*'
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
- 'feature/f3-2*'
paths:
- 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
pull_request:
branches:
- 'main'
paths:
- 'android-app/**'
- 'api-v3/src/**'
- 'api-v3/src/auth/**'
- '.github/workflows/android.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs:
android:
name: Android · lint, tests, debug APK, release compile
build-debug-apk:
runs-on: ubuntu-latest
timeout-minutes: 35
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -43,111 +38,27 @@ jobs:
- name: Android SDK
uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Android API 36
run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0'
- name: Gradle 8.13
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
uses: gradle/actions/setup-gradle@v4
with:
gradle-version: '8.13'
- 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: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug :app:lintRelease
- 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: 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
- name: Assemble debug
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant
- name: Unit tests
working-directory: android-app
run: gradle --no-daemon :app:assembleRelease
run: gradle --no-daemon :app:testDebugUnitTest
- 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.8"
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
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/dist/*
name: DH-Inspeccion-F3.2-0.12.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
if-no-files-found: error
retention-days: 30
- name: Upload unsigned release candidate
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-UNSIGNED
path: android-app/app/build/outputs/apk/release/app-release-unsigned.apk
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
+69 -212
View File
@@ -4,13 +4,7 @@ on:
pull_request:
branches: [main]
push:
branches:
- main
- 'feature/**'
- 'fix/**'
- 'chore/**'
- 'release/**'
workflow_dispatch:
branches: [main]
permissions:
contents: read
@@ -58,10 +52,9 @@ jobs:
- run: npm run build
contract:
name: Docker / migrations / production images
name: Docker / scripts contract
runs-on: ubuntu-latest
needs: [api, web]
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }}
steps:
- uses: actions/checkout@v4
- name: Validate shell scripts
@@ -76,7 +69,7 @@ jobs:
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: Rehearse migrations and real API startup on clean PostGIS
- name: Rehearse migrations on clean PostGIS
run: |
set -Eeuo pipefail
cleanup() {
@@ -87,9 +80,12 @@ jobs:
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.
# The historical production reset is a one-shot operational migration,
# not a bootstrap migration: it requires production data/configuration
# that cannot exist at its timestamp in a database rebuilt from zero.
# Prove the clean chain reaches that exact guard, then mark only that
# one-shot migration as already applied and continue the reproducible
# schema chain. The historical migration itself remains untouched.
bootstrap_log="$(mktemp)"
set +e
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
@@ -108,14 +104,17 @@ jobs:
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;
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 $$;
@@ -123,35 +122,17 @@ jobs:
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;
real_inventory integer;
source_areas integer;
source_yacimientos 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
@@ -160,186 +141,81 @@ jobs:
'F5CanonicalInventoryHierarchy1790087150000',
'F5AuthoritativeTerritory1790087200000',
'F5OperationalContextCompatibility1790087250000',
'F5AuthoritativeInventoryCatalog1790087300000',
'F51CleanManualInventory1790087400000'
'F5AuthoritativeInventoryCatalog1790087300000'
);
IF f5_migrations <> 6 THEN
RAISE EXCEPTION 'Expected 6 F5/F5.1 migrations, got %', f5_migrations;
IF f5_migrations <> 5 THEN
RAISE EXCEPTION 'Expected 5 F5 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;
SELECT COUNT(*) INTO real_inventory
FROM assets WHERE is_inventory_instance=true AND information_status<>'INACTIVE';
IF real_inventory <> 0 THEN
RAISE EXCEPTION 'Fresh F5 database must start with 0 real Inventory instances, got %', real_inventory;
END IF;
SELECT COUNT(*) 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;
SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA'),
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')
INTO source_areas,source_yacimientos
FROM source_documents document
JOIN asset_source_documents link ON link.document_id=document.id
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE document.document_number='DH-F5-TERRITORY';
IF source_areas <> 64 OR source_yacimientos <> 230 THEN
RAISE EXCEPTION 'F5 territory preload mismatch: areas %, yacimientos %', source_areas,source_yacimientos;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'modelo_relacional:%';
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
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;
RAISE EXCEPTION 'F5 family preload mismatch: installations %, subinstallations %', source_installations,source_subinstallations;
END IF;
SELECT COUNT(*) INTO source_findings
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='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;
WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN
RAISE EXCEPTION 'F5 finding preload mismatch: %', source_findings;
END IF;
END $$;
SQL
# 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
# In containerized runners (Gitea DinD), 127.0.0.1 of the job
# is not the Docker daemon host. Probe the production API from
# inside its own container so this barrier works on GitHub and Gitea.
api_ready=0
for _ in $(seq 1 30); do
if docker compose --env-file .env.example exec -T api \
node -e 'fetch(`http://127.0.0.1:${process.env.API_PORT}/api/v3/health`).then(async r => { const t = await r.text(); process.stdout.write(t); if (!r.ok) process.exit(1); }).catch(() => process.exit(1))' \
>/tmp/dhv2-health.json 2>/dev/null; then
api_ready=1
break
fi
sleep 2
# Prove the five F5 migrations are actually reversible on a clean state.
for _ in 1 2 3 4 5; do
docker compose --env-file .env.example --profile tools run --rm migrate npm run migration:revert
done
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
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE f5_migrations integer; instance_column integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
WHERE name LIKE 'F5%1790087%';
IF f5_migrations <> 0 THEN
RAISE EXCEPTION 'F5 rollback left % migration rows behind', f5_migrations;
END IF;
SELECT COUNT(*) INTO instance_column
FROM information_schema.columns
WHERE table_schema='public' AND table_name='assets' AND column_name='is_inventory_instance';
IF instance_column <> 0 THEN
RAISE EXCEPTION 'F5 rollback left is_inventory_instance behind';
END IF;
END $$;
SQL
# Reapply them once more. Each F5 migration performs its own source/cardinality checks.
docker compose --env-file .env.example --profile tools run --rm migrate
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 -Atc \
"SELECT CASE WHEN COUNT(*)=5 THEN 'F5_REAPPLY_OK' ELSE 'F5_REAPPLY_FAILED:'||COUNT(*) END FROM typeorm_migrations WHERE name IN ('F5InventoryPhysicalInstance1790087100000','F5CanonicalInventoryHierarchy1790087150000','F5AuthoritativeTerritory1790087200000','F5OperationalContextCompatibility1790087250000','F5AuthoritativeInventoryCatalog1790087300000');" \
| grep -Fx 'F5_REAPPLY_OK'
- name: VPS-equivalent isolated API preflight
run: |
set -Eeuo pipefail
image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}"
@@ -354,22 +230,3 @@ jobs:
docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
promote-deploy:
name: Promote verified main to deploy
runs-on: ubuntu-latest
needs: [api, web, contract]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fast-forward deploy to the verified commit
run: |
set -Eeuo pipefail
git fetch origin deploy main
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
git merge-base --is-ancestor origin/deploy "$GITHUB_SHA"
git push origin "$GITHUB_SHA:refs/heads/deploy"
@@ -1,26 +0,0 @@
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
-48
View File
@@ -1,48 +0,0 @@
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
+5 -26
View File
@@ -2,30 +2,9 @@
Repositorio del sistema DH Inspección V2.
## Componentes
## Estructura
- `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.
- `api-v3/`: API v3.
- `web-v2/`: aplicación web.
- `scripts/`: utilidades operativas y de despliegue.
- `docs/`: documentación técnica.
-11
View File
@@ -1,11 +0,0 @@
# 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.
-20
View File
@@ -1,20 +0,0 @@
# Checklist final Android 0.19.3
La candidata sólo se promueve si el mismo SHA cumple:
- iniciar Inspección;
- crear/abrir Acta sin pedir urgencia ni Inventario;
- entrar a **Agregar Hallazgo** y recién allí seleccionar o crear Instalación/Subinstalación;
- exigir GPS + fotografía para Inventario nacido en campo antes de Hallazgos;
- registrar Hallazgos sobre el elemento seleccionado;
- al cerrar el contenido, exigir Urgente/No urgente, persistir esa decisión y mostrar **Pendiente de firma**;
- exigir por cada Acta los datos del **Representante de la empresa**: nombres y apellidos, DNI, cargo/función y email;
- permitir precargar esos datos desde el Acta anterior de la misma Inspección, pero exigir confirmación y firma nueva en cada Acta;
- resolver por Acta **firma en conformidad**, **firma en disconformidad con motivo** o **negativa a firmar con motivo**;
- verificar que la urgencia existe sólo a nivel **Acta** y nunca a nivel Hallazgo;
- completar firma del Inspector y manifestación/firma o negativa de la empresa;
- mostrar el Acta como **Firmada y cerrada**;
- permitir cerrar la Inspección sólo con todas las Actas activas firmadas y cerradas;
- no mostrar **Inventario de campo** como acción paralela de la Inspección;
- no exponer estados internos en inglés en la interfaz de campo;
- Android CI / RC, DH V2 CI y auditoría de dependencias en verde.
-163
View File
@@ -1,163 +0,0 @@
# DH Inspección Android · release de campo 0.19.11
## Candidata vigente
- `versionName`: **0.19.11**; `versionCode`: **39**.
- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-9 / WEB 0.23.0-6.
- Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado.
- La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión.
- Un acta anterior, ya sellada, conserva el texto originalmente registrado.
## Referencia de 0.19.10
### Candidata anterior
- Fase funcional: **Flujo final de campo · Inspección → Acta → Hallazgos → Firma**.
- `versionName`: **0.19.10**.
- `versionCode`: **38**.
- Application ID release: `com.korexlabs.dhinspeccion`.
- Application ID debug/QA: `com.korexlabs.dhinspeccion.debug`.
- API: `https://dhv2.korexlabs.com/api/v3/`.
- Servidor compatible de esta candidata: **API 0.29.0-8 / WEB 0.23.0-5**.
La variante debug es independiente de la app productiva y puede instalarse para QA/presentación sin sobrescribir una instalación release histórica.
### Ajustes 0.19.10
- **Trabajo offline seguro:** Room conserva operaciones pendientes y WorkManager las sincroniza cuando vuelve la red, respetando el orden en que fueron creadas.
- La cola offline queda separada por usuario autenticado para impedir que una sesión sincronice trabajo de otra persona en el mismo dispositivo.
- Inspecciones, Actas, Inventario, Hallazgos, fotos, representante, manifestación y cierre conservan una identidad de cliente estable para reintentos idempotentes.
- Las listas de trabajo, Inventario, Actas, cierres, Hallazgos y estado de firma usan caché local por usuario para poder consultar contexto ya descargado sin conectividad.
- Antes de incorporar una foto de Hallazgo se muestra **Revisar fotografía** con **Usar esta foto**, **Volver a tomar** y **Eliminar foto**.
- Corrige el cierre que enviaba `uploadMode=ONLINE`; el contrato vigente usa `IMMEDIATE` o `DEFERRED`.
- La APK consulta el estado real de la firma reutilizable del Inspector y no afirma que existe cuando todavía no fue configurada.
- El Dashboard de Actas prioriza el **PDF real del Acta** y muestra directamente las fotografías de Hallazgos y del Inventario relacionado.
- En toda la interfaz activa, la antigua expresión “clasificación técnica” se presenta como **Tipo de instalación** o **Tipo de subinstalación**.
- Corrige el cierre de Actas evitando `NaN` en el versionado de LOCKED/SEALED.
- El cierre pasa a **Para firmar**; la APK captura sólo la manifestación/firma del acompañante.
- La firma del Inspector se guarda una vez en Mi perfil y se copia de forma inmutable al sellar cada Acta.
- Elimina la tarjeta duplicada del Acta seleccionada y simplifica la jerarquía visual inspirada en la referencia Figma.
- El encabezado de **Mis inspecciones** separa las acciones para que **Salir** nunca se comprima ni se corte en pantallas angostas.
- El alta de Hallazgos corrige la captura de versión que podía transformar el número en `NaN` después del INSERT y devolver error 500.
- Un Hallazgo admite **múltiples fotografías con GPS**; después de cada captura queda disponible **Agregar otra foto con GPS** y se muestra el contador.
## Procedimiento operativo validado
La APK 0.19.10 fija como recorrido principal de campo:
1. **Iniciar Inspección**. Al iniciarla se habilita el circuito de **Actas y Hallazgos**; Inventario no es una acción independiente de la Inspección.
2. **Crear o abrir un Acta**. Puede haber varias Actas dentro de una misma Inspección, pero sólo una puede permanecer en elaboración al mismo tiempo. La urgencia todavía no se define.
3. **Agregar Hallazgos**. Recién desde esta acción se selecciona la Instalación/Subinstalación afectada.
4. Si el elemento ya existe, se lo selecciona desde el Inventario del Área/Yacimiento de la Inspección.
5. Si no existe, se da de alta desde campo sin abandonar el Acta.
6. Un elemento nuevo debe completar **GPS + fotografía** antes de poder recibir Hallazgos.
7. Antes del cierre se identifican, para esa Acta, los datos del **Representante de la empresa** que acompañó el recorrido: nombres y apellidos, DNI, cargo/función y email. Si coincide con el Acta anterior, la APK puede precargarlos, pero deben confirmarse nuevamente.
8. Al terminar el contenido del Acta se elige **Urgente / No urgente** y luego se usa **Cerrar contenido y pasar a firma**. La urgencia pertenece al Acta completa, nunca a cada Hallazgo; desde ese momento el contenido queda inmutable y el estado visible es **Para firmar**.
9. En **Para firmar**, el acompañante de la empresa registra su manifestación: **conformidad**, **disconformidad con motivo obligatorio**, **negativa a firmar con motivo obligatorio** o **ausencia documentada**. La firma del Inspector no se dibuja en la tablet: se guarda una sola vez desde **Mi perfil** del Dashboard y el servidor copia esa firma de forma inmutable a cada Acta al usar **Aplicar mi firma y cerrar Acta**.
10. La Inspección sólo puede cerrarse cuando todas sus Actas no canceladas están firmadas y cerradas.
La nomenclatura técnica interna de API (`DRAFT`, `LOCKED`, `SEALED`, etc.) no se muestra al inspector: la interfaz usa textos operativos en castellano.
## Alta de Instalaciones y Subinstalaciones
El alta móvil queda alineada con el Dashboard y con las validaciones del servidor.
### Instalación
- contexto territorial: Yacimiento/Área de la Inspección;
- clasificación técnica compatible;
- **Nombre técnico** obligatorio;
- Nombre habitual opcional;
- Descripción opcional;
- atributos técnicos definidos por el tipo; sólo los marcados como obligatorios bloquean el guardado;
- código DH generado por el sistema cuando corresponde;
- GPS de la tablet;
- fotografía obligatoria antes de crear Hallazgos.
### Subinstalación
Además de lo anterior requiere elegir primero la **Instalación padre**. El buscador consulta mientras se escribe y cada resultado es seleccionable tocando la tarjeta.
La clasificación y los atributos se obtienen dinámicamente desde el mismo catálogo administrado por el Dashboard; la APK no mantiene listas técnicas paralelas.
## Alta de Hallazgos
El alta de Hallazgos usa un selector desplegable con búsqueda por nombre. La pantalla muestra sólo la información necesaria para decidir, mantiene **Otro / No está en la lista** como salida manual y evita exponer códigos o leyendas internas del catálogo. La fecha de corrección no se solicita al inspector al crear un Hallazgo; los plazos administrativos se gestionan fuera de este alta de campo. Los campos de carga son compactos y el teclado avanza con **Siguiente** entre ellos.
## Actas y firma
La terminología visible se simplifica:
- `DRAFT`**En elaboración**;
- `LOCKED`**Pendiente de firma**;
- `SEALED`**Firmada y cerrada**.
Cerrar el contenido no cierra automáticamente la Inspección y tampoco obliga a terminar la firma en ese instante: puede continuarse con otra Acta. La urgencia se decide en ese cierre, nunca al crear el borrador. Una vez cerrado el contenido, el Acta es inmutable.
Después de la firma/cierre definitivo se mantiene el circuito documental existente: PDF del Acta e informe INF editable (DOCX) para oficina.
## Castellano e identidad visual
Se revisaron las pantallas activas de campo para evitar exponer estados y opciones internas en inglés. Entre otros cambios: Pasaporte/Otro en documentos, estados de Inspección/Acta/Hallazgo en castellano, Correo electrónico, Huella de integridad y terminología de cierre orientada al usuario.
La pantalla de ingreso y el desbloqueo biométrico utilizan la identidad visual de Mendoza ya incorporada al proyecto.
## 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. pruebas unitarias 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 cambios que afecten al servidor.
## Firma release
La clave histórica de firma **no se versiona ni se reemplaza**. La CI compila la variante release para detectar roturas, pero una APK productiva que deba actualizar `com.korexlabs.dhinspeccion` tiene que firmarse con la clave histórica.
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 si se distribuirá como actualización productiva;
- realizar actualización sobre al menos una tablet con la versión productiva anterior cuando corresponda;
- ejecutar smoke funcional: ingreso, iniciar Inspección, crear Acta, seleccionar/crear Instalación o Subinstalación, GPS/foto, Hallazgo, cerrar Acta → Pendiente de firma, firma/manifestación, cierre de Acta y cierre de Inspección;
- registrar el SHA Git y SHA-256 de la APK distribuida.
## Revisión 0.19.1
- Renovación de sesión coordinada entre los cuatro clientes API; no cierra sesión por pérdida de red, HTTP 429/500 o cancelación.
- Una respuesta tardía no puede restaurar una sesión cerrada ni usar la de otro inspector.
- Guardia de escrituras contra doble toque y conteo correcto de operaciones pendientes.
- Búsquedas descartan respuestas antiguas. Cambio de inspección limpia el Acta anterior.
- Mensajes visibles de permisos/GPS en evidencia y respeto de barras de sistema/teclado.
- Diez nuevas pruebas de comportamiento sobre sesión y concurrencia.
### Alcance real
Esta candidata requiere conexión. No implementa trabajo offline ni cola persistente de sincronización; un fallo de red no equivale a guardado. Tras un timeout de escritura debe verificarse el registro antes de repetir. No se presenta el artefacto debug como una release productiva. La firma histórica y el smoke en tablet siguen siendo requisitos para distribuir la release final.
### Segunda pasada funcional
- Coordenadas normalizadas al contrato API (6 decimales, precisión 3).
- Yacimiento seleccionable para Hallazgos y como padre explícito de nuevas Instalaciones. Se elimina el fallback que podía presentar un tipo Yacimiento como alta de Instalación.
- Formulario Datos técnicos sobre el elemento seleccionado: carga y guarda las definiciones/valores por familia mediante los endpoints existentes del Dashboard; valida obligatorios, números, Sí/No, fechas y opciones. Es un paso separado del alta estructural y GPS/foto.
- Se agregan siete pruebas para coordenadas y valores técnicos.
## Revisión 0.19.3 · flujo conceptual corregido
- Se elimina **Inventario de campo** como acción independiente de la pantalla de Inspección.
- El alta o selección de Inventario existe únicamente dentro de **Agregar Hallazgo**.
- El borrador de Acta nace vacío, sin Inventario y sin urgencia predeterminada.
- La urgencia se exige al cerrar el Acta y se persiste en la misma operación que la vuelve inmutable.
- `inspection_acts.urgency` puede quedar pendiente durante el borrador; el bloqueo exige la decisión y calcula el vencimiento desde allí.
-49
View File
@@ -1,49 +0,0 @@
# DH Android 0.19.1 · revisión de candidata
## Idea funcional verificada contra código y API
La app es la herramienta del inspector en campo. Oficina administra territorio, operadoras, clasificaciones, campos y catálogo; la APK consulta esas fuentes. Una Inspección contiene varias Actas, con una sola en elaboración simultánea. El Hallazgo pertenece al Acta y al elemento seleccionado. Cerrar contenido lo deja inmutable y pendiente de firma. Firma del inspector y manifestación/firma o negativa de empresa permiten sellar el Acta. La Inspección exige todas sus Actas activas selladas.
Yacimiento, Instalación y Subinstalación son destinos de Hallazgos. Una Instalación nueva debe crearse dentro de un Yacimiento y una Subinstalación dentro de una Instalación compatible. El inventario nacido en campo necesita GPS y foto antes del Hallazgo.
## Hallazgos corregidos
| Problema observado | Corrección |
| --- | --- |
| Cuatro clientes renovaban por separado la misma sesión | Coordinador compartido; protege rotación, logout y cambio de usuario |
| Cortes de red durante refresh borraban la sesión | Sólo una respuesta 401 de la sesión vigente la invalida |
| Operaciones solapadas liberaban ocupado antes de terminar | Conteo de pendientes y rechazo del segundo guardado |
| Respuestas de búsquedas antiguas sobrescribían resultados recientes | Generación de búsqueda y verificación del ID de inspección |
| Un Acta anterior podía persistir al cambiar de inspección | Limpieza de estado de Acta al abrir otra inspección |
| Coordenadas crudas excedían decimales admitidos | Normalización 6 decimales para coordenadas y 3 para precisión |
| Inventario móvil ocultaba Yacimientos | Selección para Hallazgos y alta de Instalación dentro de Yacimiento |
| Alta de Instalación podía adoptar otro tipo por fallback | Eliminado; pide elegir el Yacimiento padre correcto |
| Datos técnicos por familia no estaban editables | Formulario conectado a technical-values; mismos campos de oficina |
| Fallos de GPS/permisos en evidencia sin mensaje | Error visible y accionable |
| Barras del sistema/teclado podían tapar controles | Insets de sistema y teclado en la raíz |
## Evidencia automática
25 pruebas Android: 8 contratos existentes y 17 pruebas nuevas de comportamiento (sesión 7, concurrencia 3, GPS 3, campos técnicos 4). Lint debug/release y compilación debug/release en workflow Android. CI general comprueba API, WEB, scripts y migraciones; la auditoría de dependencias se ejecuta separadamente. Consultar las ejecuciones del SHA de la entrega, no sólo un resultado anterior.
## Límites de esta candidata
- Requiere conexión. No existe cola persistente offline ni resolución de conflictos sin señal.
- Un timeout de escritura puede ocurrir después de guardar en servidor. Revisar el registro antes de repetir; no se afirma idempotencia general de todas las escrituras.
- Los datos técnicos por familia se completan en un paso separado tras seleccionar/crear el elemento. GPS/foto continúa siendo la barrera de Hallazgos del servidor.
- No se ejecutó aceptación en tablet física: quedan por comprobar cámara, GPS, biometría, rotación/recreación de actividad, firmas y actualización sobre la APK instalada.
- La release compila sin firma. Se requiere la clave histórica y su certificado de referencia para distribuir una actualización productiva. No crear otra clave como sustituto.
- La candidata debug usa com.korexlabs.dhinspeccion.debug y no reemplaza la app productiva. La compatibilidad con una debug anterior depende también de su certificado; no desinstalar una app con información pendiente sin revisar antes.
## Recorrido de aceptación en tablet
1. Ingresar con inspector y cargar inspecciones asignadas.
2. Iniciar una inspección, crear Acta, seleccionar Yacimiento/Instalación/Subinstalación.
3. Crear Instalación bajo Yacimiento y Subinstalación compatible; probar GPS permitido/denegado y foto tomada/cancelada.
4. Completar campos técnicos requeridos, incluyendo No booleano, decimal con coma, fecha y opción de lista; volver a abrir para comprobar persistencia.
5. Crear Hallazgo catalogado y OTROS, adjuntar evidencia.
6. Cerrar contenido, comprobar inmutabilidad y estado Pendiente de firma.
7. Registrar firmas o negativa de empresa; sellar Acta.
8. Crear otra Acta, comprobar que una pendiente impide cerrar la Inspección; completar y cerrar.
9. Cortar conexión y recuperarla: errores visibles, sin pérdida de sesión por red ni falso aviso de guardado.
10. Verificar en Dashboard los mismos registros y documentos.
+2 -8
View File
@@ -2,20 +2,18 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.kapt")
}
android {
namespace = "com.korexlabs.dhinspeccion"
compileSdk = 36
buildToolsVersion = "36.0.0"
defaultConfig {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 39
versionName = "0.19.11"
versionCode = 19
versionName = "0.12.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -75,10 +73,6 @@ dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.android.gms:play-services-location:21.3.0")
implementation("androidx.exifinterface:exifinterface:1.4.1")
implementation("androidx.room:room-runtime:2.7.2")
implementation("androidx.room:room-ktx:2.7.2")
kapt("androidx.room:room-compiler:2.7.2")
implementation("androidx.work:work-runtime-ktx:2.10.3")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
@@ -3,14 +3,11 @@
<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"
@@ -6,9 +6,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.korexlabs.dhinspeccion.data.AssetSummary
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import com.korexlabs.dhinspeccion.data.CaptureStatus
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
@@ -20,29 +17,21 @@ import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.MobileActClosure
import com.korexlabs.dhinspeccion.data.MobileActClosureHeader
import com.korexlabs.dhinspeccion.data.MobileActDetail
import com.korexlabs.dhinspeccion.data.MobileActSummary
import com.korexlabs.dhinspeccion.data.MobileActsRepository
import com.korexlabs.dhinspeccion.data.MobileVisitClosureHeader
import com.korexlabs.dhinspeccion.data.MobileResponsible
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.offline.OfflineMutationQueue
import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import java.io.File
import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val operations = OperationTracker()
private var inventorySearchGeneration = 0
private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application)
private val offlineQueue = OfflineMutationQueue(application)
var session: StoredSession? by mutableStateOf(repository.currentSession())
private set
@@ -52,14 +41,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private set
var notice: String? by mutableStateOf(null)
private set
var pendingSyncCount by mutableStateOf(0)
private set
var inspectorSignatureConfigured: Boolean? by mutableStateOf(null)
private set
var selectedActCompanySignaturePending by mutableStateOf(false)
private set
var selectedActSealPending by mutableStateOf(false)
private set
var visits: List<VisitSummary> by mutableStateOf(emptyList())
private set
@@ -68,8 +49,6 @@ 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)
@@ -90,8 +69,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
private set
init {
OfflineMutationQueue.schedule(application)
viewModelScope.launch { refreshOfflineStateInternal() }
if (session != null) loadVisits()
}
@@ -105,7 +82,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Ingresá usuario y contraseña."
return
}
launchBusy(mutation = true) {
launchBusy {
session = repository.login(identifier, password)
notice = "Sesión iniciada."
loadVisitsInternal()
@@ -119,7 +96,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
visits = emptyList()
visit = null
inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
@@ -134,11 +110,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun openVisit(id: String) = launchBusy {
val loadedVisit = repository.visit(id)
clearActState()
visit = loadedVisit
visit = repository.visit(id)
inventory = emptyList()
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
@@ -146,11 +119,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
fun closeVisitView() {
if (busy) return
inventorySearchGeneration += 1
visit = null
inventory = emptyList()
inventoryParentId = null
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
@@ -160,207 +130,88 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun startVisit() {
val id = visit?.id ?: return
val previousVisit = visit
launchBusy(mutation = true) {
offlineQueue.enqueueVisitStart(id)
val localStartedAt = Instant.now().toString()
visit = visit?.copy(status = "IN_PROGRESS", actualStartedAt = localStartedAt)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
visit = repository.visit(id)
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
} else if (result.retry) {
inventoryParentId = visit?.scopeAsset?.id ?: visit?.operationalArea?.id
notice = "Inspección iniciada en el dispositivo. Podés trabajar sin conexión; se sincronizará automáticamente."
} else {
visit = previousVisit
error = result.lastError ?: "No se pudo iniciar la Inspección."
}
launchBusy {
visit = repository.startVisit(id)
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
}
}
fun reloadActs() {
val visitId = visit?.id ?: return
launchBusy {
loadActsInternal(visitId, selectDraft = selectedAct == null)
refreshInspectorSignatureStatusInternal()
refreshOfflineStateInternal()
}
}
fun syncPendingNow() {
launchBusy {
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
selectedAct?.id?.let { actId ->
runCatching { refreshSelectedActInternal(actId) }
}
if (result.pending == 0) {
notice = if (result.synced > 0) "Sincronización completa. ${result.synced} cambio${if (result.synced == 1) "" else "s"} enviado${if (result.synced == 1) "" else "s"}." else "No hay cambios pendientes de sincronización."
} else if (result.retry) {
notice = "Sin conexión. Los cambios siguen guardados en este dispositivo."
} else {
error = result.lastError ?: "Quedan cambios pendientes de sincronización."
}
}
}
fun refreshInspectorSignatureStatus() {
viewModelScope.launch {
refreshInspectorSignatureStatusInternal()
if (inspectorSignatureConfigured == true && pendingSyncCount > 0) {
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
val actId = selectedAct?.id
if (actId != null && !selectedActSealPending) {
runCatching { refreshSelectedActInternal(actId) }
}
when {
result.pending == 0 -> notice = "Firma verificada y cambios pendientes sincronizados."
result.retry -> notice = "Firma verificada. Los cambios siguen guardados hasta recuperar conexión."
else -> error = result.lastError ?: "Quedan cambios pendientes de sincronización."
}
}
}
launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) }
}
fun selectAct(actId: String) {
launchBusy {
selectedAct = actsRepository.get(actId)
actClosure = actsRepository.closure(actId)
refreshInspectorSignatureStatusInternal()
refreshOfflineStateInternal()
clearFindingState()
}
}
fun createAct() {
fun createActForSelectedInventory(urgency: String = "NON_URGENT") {
val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") {
error = "La Inspección debe estar en curso para crear un Acta."
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en elaboración. Cerrala o cancelala antes de crear la siguiente."
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
launchBusy(mutation = true) {
val actId = DhRepository.newOperationId()
val occurredAt = Instant.now().toString()
offlineQueue.enqueueActCreate(actId, currentVisit.id, currentVisit.code, occurredAt)
val localCode = "ACTA-PEND-${actId.take(6).uppercase()}"
val local = MobileActDetail(
id = actId,
visitId = currentVisit.id,
code = localCode,
status = "DRAFT",
occurredAt = occurredAt,
title = "Acta de inspección ${currentVisit.code}",
summary = "Se inició la inspección ${currentVisit.code}.",
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
}
launchBusy {
val created = actsRepository.create(
currentVisit.id,
asset.id,
currentVisit.code,
urgency,
)
selectedAct = local
acts = acts + MobileActSummary(
id = actId, visitId = currentVisit.id, code = localCode, status = "DRAFT", occurredAt = occurredAt,
title = local.title, summary = local.summary,
)
actClosure = MobileActClosure(
act = MobileActClosureHeader(
id = actId, code = localCode, status = "DRAFT", visitId = currentVisit.id, currentVersion = 0,
),
visit = MobileVisitClosureHeader(
id = currentVisit.id, code = currentVisit.code, status = currentVisit.status, actualClosedAt = currentVisit.actualClosedAt,
),
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
loadActsInternal(currentVisit.id, selectDraft = false)
refreshSelectedActInternal(actId)
notice = "${selectedAct?.code ?: localCode} creada. La urgencia se define recién al cerrar el Acta; los Hallazgos se agregan desde su propio flujo."
} else {
notice = "Acta creada en el dispositivo. Podés continuar trabajando; recibirá su código oficial cuando vuelva internet."
selectedAct = created
actClosure = actsRepository.closure(created.id)
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) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
}
}
fun searchInventory(search: String, parentId: String? = null) {
val currentVisit = visit ?: return
val generation = ++inventorySearchGeneration
val id = visit?.id ?: return
launchBusy {
val result = repository.fieldInventory(currentVisit.id, search, parentId).data
if (generation == inventorySearchGeneration && visit?.id == currentVisit.id) inventory = result
inventory = repository.fieldInventory(id, search, parentId).data
}
}
fun loadFieldTypes(parentId: String? = null) {
val currentVisit = visit ?: return
val id = visit?.id ?: return
launchBusy {
val effectiveParentId = parentId ?: currentVisit.scopeAsset?.id ?: currentVisit.operationalArea?.id
inventorySearchGeneration += 1
inventoryParentId = effectiveParentId
fieldTypes = repository.fieldTypes(currentVisit.id, parentId).data
inventory = repository.fieldInventory(currentVisit.id, null, effectiveParentId).data
fieldTypes = repository.fieldTypes(id, parentId).data
}
}
private suspend fun reloadCurrentInventory(visitId: String) {
val effectiveParentId = inventoryParentId ?: visit?.scopeAsset?.id ?: visit?.operationalArea?.id
inventory = repository.fieldInventory(visitId, null, effectiveParentId).data
}
private suspend fun ensureAssetInActOffline(
actId: String,
assetId: String,
code: String,
name: String,
typeName: String?,
): OfflineMutationQueue.SyncResult {
if (selectedAct?.assets?.none { it.id == assetId } != false) {
offlineQueue.enqueueActAsset(actId, assetId)
selectedAct = selectedAct?.copy(
assets = selectedAct!!.assets + AssetSummary(assetId, code, name, typeName),
assetCount = selectedAct!!.assetCount + 1,
)
acts = acts.map { if (it.id == actId) it.copy(assetCount = selectedAct?.assetCount ?: it.assetCount) else it }
}
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) selectedAct = actsRepository.get(actId)
return result
}
fun selectExisting(item: FieldInventoryItem) {
val visitId = visit?.id ?: return
launchBusy(mutation = true) {
offlineQueue.enqueueFieldAssetSelect(visitId, item.id)
selectedFieldAsset = FieldAssetDetail(
context = null,
asset = item.copy(selectedInInspection = true),
selectedInInspection = true,
capture = CaptureStatus(
captureRequired = item.captureRequired,
hasGeometry = item.hasGeometry,
creationGpsCaptured = item.hasGeometry,
fieldPhotoCount = item.fieldPhotoCount,
readyForFinding = item.readyForFinding,
),
)
val sync = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (sync.pending == 0) {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
runCatching { reloadCurrentInventory(visitId) }
notice = "Inventario agregado a la Inspección."
} else {
notice = "Inventario seleccionado en el dispositivo. Se sincronizará automáticamente al volver internet."
}
launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la Inspección."
inventory = repository.fieldInventory(visitId, null, null).data
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
ensureAssetInActOffline(draft.id, item.id, item.code, item.name, item.type?.name)
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, item.id, draft.id)
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
@@ -378,7 +229,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
latitude: Double,
longitude: Double,
accuracyM: Double?,
description: String? = null,
) {
val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") {
@@ -386,62 +236,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
if (type.familyRequired && familyId == null) {
error = "Elegí el Tipo de instalación o subinstalación correspondiente."
error = "Elegí una familia técnica o la opción Otro / no catalogado."
return
}
launchBusy(mutation = true) {
val assetId = DhRepository.newOperationId()
val capturedAt = Instant.now().toString()
launchBusy {
val request = CreateFieldInventoryRequest(
clientGeneratedId = assetId,
typeId = type.id,
parentId = parentId,
familyId = familyId,
name = name.trim(),
commonName = commonName?.trim()?.takeIf { it.isNotBlank() },
description = description?.trim()?.takeIf { it.isNotBlank() },
attributes = attributes,
deviceLatitude = FieldCoordinates.latitude(latitude),
deviceLongitude = FieldCoordinates.longitude(longitude),
deviceAccuracyM = accuracyM?.let(FieldCoordinates::accuracy),
deviceCapturedAt = capturedAt,
deviceLatitude = latitude,
deviceLongitude = longitude,
deviceAccuracyM = accuracyM,
deviceCapturedAt = Instant.now().toString(),
)
offlineQueue.enqueueFieldAssetCreate(assetId, visitId, request)
val localAsset = FieldInventoryItem(
id = assetId,
code = "PEND-${assetId.take(6).uppercase()}",
name = request.name,
commonName = request.commonName,
informationStatus = "DRAFT",
dataOrigin = "FIELD_SURVEY",
type = AssetSummary(type.id, type.code, type.name, type.name),
selectedInInspection = true,
captureRequired = true,
hasGeometry = true,
fieldPhotoCount = 0,
readyForFinding = false,
)
selectedFieldAsset = FieldAssetDetail(
asset = localAsset,
selectedInInspection = true,
capture = CaptureStatus(
captureRequired = true,
hasGeometry = true,
creationGpsCaptured = true,
fieldPhotoCount = 0,
readyForFinding = false,
),
)
inventory = inventory + localAsset
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
selectedFieldAsset = repository.selectFieldAsset(visitId, assetId)
runCatching { reloadCurrentInventory(visitId) }
notice = "Inventario creado con GPS. Falta la fotografía obligatoria. Si ya existía, podés fusionarlo antes de continuar."
} else {
notice = "Inventario creado y guardado en el dispositivo. Sacá la foto obligatoria; se sincronizará automáticamente al volver internet."
}
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
}
}
@@ -456,7 +269,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Explicá brevemente por qué se trata de un duplicado."
return
}
launchBusy(mutation = true) {
launchBusy {
val result = repository.mergeFieldAsset(
visitId = visitId,
assetId = source.id,
@@ -465,12 +278,11 @@ 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."
reloadCurrentInventory(visitId)
inventory = repository.fieldInventory(visitId, null, null).data
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
ensureAssetInActOffline(
draft.id, result.canonical.id, result.canonical.code, result.canonical.name, result.canonical.typeName,
)
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
}
}
@@ -484,42 +296,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) {
val visitId = visit?.id ?: return
val asset = selectedFieldAsset?.asset ?: return
launchBusy(mutation = true) {
offlineQueue.enqueueFieldAssetPhoto(
assetId = asset.id,
launchBusy {
val response = repository.uploadFieldPhoto(
visitId = visitId,
source = file,
assetId = asset.id,
file = file,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
)
val localCapture = selectedFieldAsset?.capture?.copy(
hasGeometry = true,
creationGpsCaptured = true,
fieldPhotoCount = (selectedFieldAsset?.capture?.fieldPhotoCount ?: 0) + 1,
readyForFinding = true,
) ?: CaptureStatus(
captureRequired = true, hasGeometry = true, creationGpsCaptured = true, fieldPhotoCount = 1, readyForFinding = true,
)
selectedFieldAsset = selectedFieldAsset?.copy(
asset = asset.copy(hasGeometry = true, fieldPhotoCount = localCapture.fieldPhotoCount, readyForFinding = true),
capture = localCapture,
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
selectedFieldAsset = repository.selectFieldAsset(visitId, asset.id)
runCatching { reloadCurrentInventory(visitId) }
notice = "Inventario listo: ubicación y foto registradas."
selectedFieldAsset = selectedFieldAsset?.copy(capture = response.capture)
notice = if (response.capture.readyForFinding) {
"Captura completa: GPS y fotografía registrados."
} else {
notice = "Foto del Inventario guardada en el dispositivo. Ya podés continuar; se sincronizará al volver internet."
"Fotografía registrada."
}
inventory = repository.fieldInventory(visitId, null, null).data
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
val current = selectedFieldAsset?.asset ?: asset
ensureAssetInActOffline(draft.id, current.id, current.code, current.name, current.type?.name)
loadFindingOptionsInternal(visitId, current.id, draft.id)
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, asset.id, draft.id)
} else if (response.capture.readyForFinding) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
}
}
@@ -530,12 +328,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val assetId = selectedFieldAsset?.asset?.id ?: return
val draft = selectedDraftAct()
if (draft == null) {
error = "Creá o seleccioná el Acta en elaboración antes de registrar Hallazgos."
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
return
}
launchBusy(mutation = true) {
val asset = selectedFieldAsset?.asset ?: return@launchBusy
ensureAssetInActOffline(draft.id, assetId, asset.code, asset.name, asset.type?.name)
launchBusy {
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id)
}
}
@@ -546,12 +344,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
customLegalBasis: String?,
description: String,
severity: Int?,
correctionDueOn: String?,
) {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id
if (actId == null) {
error = "No hay un Acta en elaboración seleccionada."
error = "No hay un Acta en borrador seleccionada."
return
}
if (description.isBlank()) {
@@ -566,61 +365,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "La gravedad debe estar entre 1 y 10."
return
}
launchBusy(mutation = true) {
val clientFindingId = DhRepository.newOperationId()
val request = CreateFieldFindingRequest(
actId = actId,
clientGeneratedId = clientFindingId,
catalogItemId = catalogItemId,
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
description = description.trim(),
severity = severity,
launchBusy {
selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create(
visitId,
assetId,
CreateFieldFindingRequest(
actId = actId,
catalogItemId = catalogItemId,
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
description = description.trim(),
severity = severity,
correctionDueOn = correctionDueOn?.trim()?.takeIf { it.isNotBlank() },
),
)
if (selectedAct?.assets?.none { it.id == assetId } != false) {
offlineQueue.enqueueActAsset(actId, assetId)
selectedFieldAsset?.asset?.let { asset ->
selectedAct = selectedAct?.copy(assets = selectedAct!!.assets + AssetSummary(
id = asset.id,
code = asset.code,
name = asset.name,
typeName = asset.type?.name,
))
}
}
offlineQueue.enqueueFindingCreate(clientFindingId, visitId, assetId, request)
val catalog = fieldFindingOptions?.catalog?.items?.firstOrNull { it.id == catalogItemId }
val localNumber = (fieldFindingOptions?.findings?.maxOfOrNull { it.findingNumber } ?: 0) + 1
val localFinding = FieldFindingItem(
id = clientFindingId,
actId = actId,
assetId = assetId,
catalogItemId = catalogItemId,
findingNumber = localNumber,
code = "PEND-H${localNumber.toString().padStart(3, '0')}",
status = "OPEN",
title = catalog?.title ?: customTitle?.trim().orEmpty().ifBlank { "Hallazgo pendiente" },
description = description.trim(),
severity = severity ?: catalog?.suggestedSeverity,
suggestedSeverity = catalog?.suggestedSeverity,
)
lastCreatedFinding = localFinding
fieldFindingOptions = fieldFindingOptions?.copy(
findings = fieldFindingOptions!!.findings + localFinding,
canAddAnother = true,
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
lastCreatedFinding = fieldFindingOptions?.findings?.firstOrNull { it.id == clientFindingId } ?: localFinding
loadActsInternal(visitId, selectDraft = false)
notice = "Hallazgo ${lastCreatedFinding?.code ?: localFinding.code} registrado. Podés agregar evidencia fotográfica."
} else {
notice = "Hallazgo guardado en el dispositivo. Podés seguir sacando fotos; todo se sincronizará automáticamente al volver internet."
}
lastCreatedFinding = response.finding
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica."
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
loadActsInternal(visitId, selectDraft = false)
}
}
@@ -633,24 +396,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
title: String? = null,
description: String? = null,
) {
launchBusy(mutation = true) {
offlineQueue.enqueueFindingPhoto(
launchBusy {
findingsRepository.uploadObservationPhoto(
findingId = findingId,
source = file,
file = file,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
title = title,
description = description,
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con GPS."
} else {
notice = "Foto guardada en el dispositivo. Se sincronizará automáticamente al volver la conexión."
}
loadEvidenceInternal(findingId)
notice = "Evidencia fotográfica registrada con GPS."
}
}
@@ -667,41 +424,24 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
phone: String?,
) {
val actId = selectedAct?.id ?: return
val normalizedEmail = email?.trim().orEmpty()
val emailValid = Regex("""^[^\s@]+@[^\s@]+\.[^\s@]+$""").matches(normalizedEmail)
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank() || !emailValid) {
error = "Completá nombres y apellidos, DNI, cargo/función y un email válido del representante de la empresa."
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) {
error = "Completá nombre, documento y cargo del responsable de la empresa."
return
}
launchBusy(mutation = true) {
val request = MobileResponsibleRequest(
attendanceStatus = "PRESENT",
fullName = fullName.trim(),
documentType = documentType,
documentNumber = documentNumber.trim(),
position = position.trim(),
email = normalizedEmail.lowercase(),
phone = phone?.trim()?.takeIf { it.isNotBlank() },
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "PRESENT",
fullName = fullName.trim(),
documentType = documentType,
documentNumber = documentNumber.trim(),
position = position.trim(),
email = email?.trim()?.takeIf { it.isNotBlank() },
phone = phone?.trim()?.takeIf { it.isNotBlank() },
),
)
offlineQueue.enqueueResponsible(actId, request)
actClosure = actClosure?.copy(responsible = MobileResponsible(
actId = actId,
attendanceStatus = request.attendanceStatus,
fullName = request.fullName,
documentType = request.documentType,
documentNumber = request.documentNumber,
position = request.position,
email = request.email,
phone = request.phone,
))
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
actClosure = actsRepository.closure(actId)
notice = "Representante de la empresa registrado para esta Acta."
} else {
notice = "Representante guardado en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
}
notice = "Responsable de empresa registrado para el Acta."
}
}
@@ -711,59 +451,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return
}
launchBusy(mutation = true) {
val request = MobileResponsibleRequest(
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
),
)
offlineQueue.enqueueResponsible(actId, request)
actClosure = actClosure?.copy(responsible = MobileResponsible(
actId = actId,
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
))
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) actClosure = actsRepository.closure(actId)
notice = if (result.pending == 0) {
"Ausencia del representante registrada. La manifestación de la empresa deberá resolverse antes de firmar y cerrar el Acta."
} else {
"Ausencia guardada en el dispositivo. Se sincronizará automáticamente cuando vuelva internet."
}
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta."
}
}
fun prepareSelectedAct(urgency: String, narrative: String) {
fun prepareSelectedAct() {
val actId = selectedAct?.id ?: return
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Definí la urgencia antes de cerrar el Acta."
return
}
val summary = narrative.trim()
if (summary.length !in 10..20000) {
error = "Describí lo actuado antes de cerrar el Acta (entre 10 y 20000 caracteres)."
return
}
launchBusy(mutation = true) {
offlineQueue.enqueueActSummary(actId, summary)
offlineQueue.enqueueActLock(actId, urgency)
selectedAct = selectedAct?.copy(status = "LOCKED", urgency = urgency, summary = summary)
acts = acts.map { if (it.id == actId) it.copy(status = "LOCKED", urgency = urgency, summary = summary) else it }
actClosure = actClosure?.copy(act = actClosure!!.act.copy(status = "LOCKED", urgency = urgency))
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
if (result.pending == 0) {
refreshSelectedActInternal(actId)
notice = "Acta cerrada como $urgencyLabel. La urgencia quedó definida sobre el Acta y su contenido quedó inmutable, pendiente de firmas."
} else {
notice = "Cierre de contenido guardado en el dispositivo como $urgencyLabel. Podés continuar con la firma; se sincronizará al recuperar internet."
}
launchBusy {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
}
}
fun reopenSelectedAct() {
error = "Un Acta pendiente de firma es inmutable y no puede volver a edición."
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
}
fun signSelectedActAsInspector(
@@ -773,7 +483,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
accuracyM: Double?,
) {
val actId = selectedAct?.id ?: return
launchBusy(mutation = true) {
launchBusy {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta."
}
@@ -788,29 +498,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
statement: String?,
) {
val actId = selectedAct?.id ?: return
launchBusy(mutation = true) {
offlineQueue.enqueueCompanySignature(
actId = actId,
source = png,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
manifestation = manifestation,
statement = statement,
launchBusy {
actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement,
)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (!selectedActCompanySignaturePending) {
actClosure = actsRepository.closure(actId)
notice = if (manifestation == "DISSENT") {
"Firma del representante registrada en disconformidad."
} else {
"Firma del representante de la empresa registrada."
}
notice = if (manifestation == "DISSENT") {
"Firma de empresa registrada con disidencia."
} else {
notice = "Firma guardada en el dispositivo. Quedó pendiente de sincronización y no se perderá si no hay internet."
"Firma de empresa registrada."
}
result.lastError?.takeIf { result.pending > 0 && !result.retry }?.let { error = it }
}
}
@@ -824,67 +520,30 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
error = "Indicá un motivo de al menos 10 caracteres."
return
}
launchBusy(mutation = true) {
offlineQueue.enqueueCompanyOutcome(actId, status, reason.trim())
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
actClosure = actsRepository.closure(actId)
notice = "Negativa a firmar asentada."
} else {
notice = "Negativa a firmar guardada en el dispositivo. Se sincronizará automáticamente al recuperar internet."
}
launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada."
}
}
fun closeSelectedAct() {
val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return
launchBusy(mutation = true) {
offlineQueue.enqueueSeal(actId)
if (inspectorSignatureConfigured == false) {
refreshOfflineStateInternal()
notice = "Cierre guardado en el dispositivo. Falta configurar la firma del inspector en Mi perfil del Dashboard; después se sincronizará automáticamente."
return@launchBusy
}
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (!selectedActSealPending) {
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} firmada y cerrada. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
} else if (result.retry) {
val localClosedAt = Instant.now().toString()
selectedAct = selectedAct?.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt)
acts = acts.map { if (it.id == actId) it.copy(status = "SEALED", sealedAt = localClosedAt, closedAt = localClosedAt) else it }
clearFindingState()
notice = "Acta cerrada en el dispositivo. Podés continuar con otra Acta; se sincronizará automáticamente al recuperar internet."
} else {
error = result.lastError ?: "El cierre quedó pendiente de sincronización."
}
launchBusy {
actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
}
}
fun closeInspection() {
val visitId = visit?.id ?: return
val previousVisit = visit
launchBusy(mutation = true) {
val closedAt = Instant.now().toString()
offlineQueue.enqueueVisitClose(visitId, closedAt)
visit = visit?.copy(status = "CLOSED", actualClosedAt = closedAt)
val result = offlineQueue.syncAll()
refreshOfflineStateInternal()
if (result.pending == 0) {
visit = repository.visit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron firmadas y disponibles para el circuito de oficina."
} else if (result.retry) {
notice = "Cierre de Inspección guardado en el dispositivo. Quedará sincronizado automáticamente cuando vuelva internet."
} else {
visit = previousVisit
error = result.lastError ?: "No se pudo cerrar la Inspección."
}
launchBusy {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
}
}
@@ -922,7 +581,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
selectedAct = null
actClosure = null
}
refreshOfflineStateInternal()
}
private suspend fun refreshSelectedActInternal(actId: String) {
@@ -953,24 +611,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
}
private suspend fun refreshInspectorSignatureStatusInternal() {
runCatching { actsRepository.selfSignatureStatus() }
.onSuccess { inspectorSignatureConfigured = it.configured }
}
private suspend fun refreshOfflineStateInternal() {
pendingSyncCount = offlineQueue.pendingCount()
val actId = selectedAct?.id
if (actId == null) {
selectedActCompanySignaturePending = false
selectedActSealPending = false
} else {
selectedActCompanySignaturePending = offlineQueue.hasPending("COMPANY_SIGNATURE", actId)
|| offlineQueue.hasPending("COMPANY_OUTCOME", actId)
selectedActSealPending = offlineQueue.hasPending("ACT_SEAL", actId)
}
}
private fun clearActState() {
acts = emptyList()
selectedAct = null
@@ -983,21 +623,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fieldFindingEvidence = emptyMap()
}
private fun launchBusy(mutation: Boolean = false, block: suspend () -> Unit) {
if (!operations.begin(mutation)) return
busy = operations.busy
private fun launchBusy(block: suspend () -> Unit) {
viewModelScope.launch {
busy = true
error = null
try {
block()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (throwable: Throwable) {
error = DhRepository.humanError(throwable)
if (repository.currentSession() == null) session = null
} finally {
operations.end()
busy = operations.busy
busy = false
}
}
}
@@ -1,16 +0,0 @@
package com.korexlabs.dhinspeccion
/** Main-thread gate: reads may overlap; a second write is never queued by a double tap. */
internal class OperationTracker {
private var pending = 0
val busy: Boolean get() = pending > 0
fun begin(mutation: Boolean): Boolean {
if (mutation && busy) return false
pending += 1
return true
}
fun end() {
check(pending > 0)
pending -= 1
}
}
@@ -5,10 +5,10 @@ import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -27,7 +27,6 @@ import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.io.IOException
import java.security.KeyStore
import java.time.Instant
import java.util.UUID
@@ -155,7 +154,6 @@ 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,
@@ -257,7 +255,6 @@ data class FieldAssetDetail(
)
data class CreateFieldInventoryRequest(
val clientGeneratedId: String? = null,
val typeId: String,
val parentId: String? = null,
val familyId: String? = null,
@@ -370,7 +367,6 @@ interface DhApi {
@Part("deviceAccuracyM") accuracy: okhttp3.RequestBody?,
@Part("deviceCapturedAt") capturedAt: okhttp3.RequestBody,
@Part("deviceLabel") deviceLabel: okhttp3.RequestBody,
@Part("operationId") operationId: okhttp3.RequestBody?,
@Part("exifLatitude") exifLatitude: okhttp3.RequestBody?,
@Part("exifLongitude") exifLongitude: okhttp3.RequestBody?,
@Part("exifCapturedAt") exifCapturedAt: okhttp3.RequestBody?,
@@ -453,14 +449,9 @@ class SecureSessionStore(context: Context) {
}
class DhRepository(context: Context) {
private val appContext = context.applicationContext
private val store = SecureSessionStore(appContext)
private val cache = OfflineJsonCache(appContext)
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val visitListAdapter = moshi.adapter(VisitListResponse::class.java)
private val visitAdapter = moshi.adapter(VisitDetail::class.java)
private val inventoryAdapter = moshi.adapter(FieldInventoryListResponse::class.java)
private val fieldTypeAdapter = moshi.adapter(FieldTypeResponse::class.java)
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
@@ -479,47 +470,25 @@ class DhRepository(context: Context) {
store.clear()
}
suspend fun visits(): VisitListResponse {
val session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
return cached("visits:${session.userId}", visitListAdapter) {
authorized { active -> api.visits("Bearer ${active.accessToken}", active.userId) }
}
suspend fun visits(): VisitListResponse = authorized { session ->
api.visits("Bearer ${session.accessToken}", session.userId)
}
suspend fun visit(id: String): VisitDetail = cached("visit:$id", visitAdapter) {
authorized { session -> api.visit("Bearer ${session.accessToken}", id) }
suspend fun visit(id: String): VisitDetail = authorized { session ->
api.visit("Bearer ${session.accessToken}", id)
}
suspend fun startVisit(id: String): VisitDetail {
val value = authorized { session -> api.startVisit("Bearer ${session.accessToken}", id) }
cache.put(userCacheKey("visit:$id"), visitAdapter.toJson(value))
return value
suspend fun startVisit(id: String): VisitDetail = authorized { session ->
api.startVisit("Bearer ${session.accessToken}", id)
}
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null): FieldInventoryListResponse {
val normalizedSearch = search?.trim()?.takeIf { it.isNotBlank() }
val baseKey = "inventory:$visitId:${parentId ?: "root"}"
return try {
val value = authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, normalizedSearch, parentId)
}
if (normalizedSearch == null) cache.put(userCacheKey(baseKey), inventoryAdapter.toJson(value))
value
} catch (error: IOException) {
val cached = cache.get(userCacheKey(baseKey))?.let(inventoryAdapter::fromJson) ?: throw error
if (normalizedSearch == null) cached else cached.copy(
data = cached.data.filter { item ->
item.code.contains(normalizedSearch, ignoreCase = true)
|| item.name.contains(normalizedSearch, ignoreCase = true)
},
)
}
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
}
suspend fun fieldTypes(visitId: String, parentId: String?): FieldTypeResponse =
cached("field-types:$visitId:${parentId ?: "root"}", fieldTypeAdapter) {
authorized { session -> api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId) }
}
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session ->
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
}
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
@@ -551,7 +520,6 @@ class DhRepository(context: Context) {
longitude: Double,
accuracyM: Double?,
capturedAt: String = Instant.now().toString(),
operationId: String? = null,
): FieldPhotoResponse = authorized { session ->
val text = "text/plain".toMediaType()
val body = file.asRequestBody("image/jpeg".toMediaType())
@@ -561,32 +529,17 @@ class DhRepository(context: Context) {
visitId = visitId,
assetId = assetId,
file = part,
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
operationId = operationId?.toRequestBody(text),
exifLatitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
exifLongitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
private suspend fun <T> cached(
key: String,
adapter: JsonAdapter<T>,
online: suspend () -> T,
): T {
return try {
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
} catch (error: IOException) {
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
@@ -594,26 +547,28 @@ class DhRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session)
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
companion object {
fun humanError(error: Throwable): String {
if (error is java.io.IOException) {
return "No se pudo conectar. Revisá la señal. Si estabas guardando, comprobá el registro antes de reintentar."
}
if (error is HttpException) {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull()
val message = runCatching { JSONObject(body.orEmpty()).optString("message") }.getOrNull()
if (message == "Error interno") return "No se pudo completar la operación. Intentá nuevamente."
if (!message.isNullOrBlank()) return message
if (error.code() >= 500) return "No se pudo completar la operación. Intentá nuevamente."
return "No se pudo procesar la solicitud (${error.code()})."
return "Error HTTP ${error.code()}"
}
return error.message ?: "Ocurrió un error inesperado"
}
@@ -1,19 +0,0 @@
package com.korexlabs.dhinspeccion.data
/** Builds the API payload using definition IDs while the form keeps stable field codes. */
internal fun fieldAttributePayload(
definitions: List<FieldAttributeDefinition>,
valuesByCode: Map<String, String>,
): Map<String, Any?> = definitions.mapNotNull { definition ->
val raw = valuesByCode[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.id to coerceFieldAttribute(definition, raw)
}.toMap()
private fun coerceFieldAttribute(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", "", "yes")
else -> raw
}
@@ -1,14 +0,0 @@
package com.korexlabs.dhinspeccion.data
import java.math.BigDecimal
import java.math.RoundingMode
internal object FieldCoordinates {
private fun normalized(value: Double, scale: Int, min: Double, max: Double): Double {
require(value.isFinite() && value in min..max) { "La ubicación GPS recibida no es válida." }
return BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP).toDouble()
}
fun latitude(value: Double) = normalized(value, 6, -90.0, 90.0)
fun longitude(value: Double) = normalized(value, 6, -180.0, 180.0)
fun accuracy(value: Double) = normalized(value, 3, 0.0, 100000.0)
}
@@ -2,10 +2,10 @@ package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -24,7 +24,6 @@ import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.io.IOException
import java.time.Instant
data class FieldFindingAct(
@@ -111,12 +110,12 @@ data class FieldFindingOptionsResponse(
data class CreateFieldFindingRequest(
val actId: String,
val clientGeneratedId: String? = null,
val catalogItemId: String? = null,
val customTitle: String? = null,
val customLegalBasis: String? = null,
val description: String,
val severity: Int? = null,
val correctionDueOn: String? = null,
)
data class FieldFindingCreateResponse(
@@ -165,7 +164,6 @@ private interface FieldFindingsApi {
@Part("longitude") longitude: RequestBody,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
@Part("operationId") operationId: RequestBody?,
): FieldFindingEvidence
@POST("auth/mobile/refresh")
@@ -177,12 +175,9 @@ private interface FieldFindingsApi {
* F3.2 exige que la APK identifique explícitamente el Acta activa.
*/
class FieldFindingsRepository(context: Context) {
private val appContext = context.applicationContext
private val store = SecureSessionStore(appContext)
private val cache = OfflineJsonCache(appContext)
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val optionsAdapter = moshi.adapter(FieldFindingOptionsResponse::class.java)
private val evidenceAdapter = moshi.adapter(FieldFindingEvidenceListResponse::class.java)
private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
@@ -190,28 +185,10 @@ class FieldFindingsRepository(context: Context) {
.build()
.create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse {
val exactKey = "finding-options:$visitId:$assetId:$actId"
val templateKey = "finding-options-template:$visitId:$assetId"
return try {
val value = authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
}
val json = optionsAdapter.toJson(value)
cache.put(userCacheKey(exactKey), json)
cache.put(userCacheKey(templateKey), json)
value
} catch (error: IOException) {
cache.get(userCacheKey(exactKey))?.let(optionsAdapter::fromJson)
?: cache.get(userCacheKey(templateKey))?.let(optionsAdapter::fromJson)?.copy(
act = FieldFindingAct(id = actId, code = "Acta local pendiente", status = "DRAFT"),
findings = emptyList(),
assetIncludedInAct = true,
canAddAnother = true,
)
?: throw error
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
}
}
suspend fun create(
visitId: String,
@@ -221,10 +198,9 @@ class FieldFindingsRepository(context: Context) {
api.create("Bearer ${session.accessToken}", visitId, assetId, request)
}
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse =
cached("finding-evidence:$findingId", evidenceAdapter) {
authorized { session -> api.evidence("Bearer ${session.accessToken}", findingId) }
}
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session ->
api.evidence("Bearer ${session.accessToken}", findingId)
}
suspend fun uploadObservationPhoto(
findingId: String,
@@ -235,7 +211,6 @@ class FieldFindingsRepository(context: Context) {
title: String? = null,
description: String? = null,
capturedAt: String = Instant.now().toString(),
operationId: String? = null,
): FieldFindingEvidence = authorized { session ->
val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData(
@@ -252,28 +227,13 @@ class FieldFindingsRepository(context: Context) {
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = FieldCoordinates.latitude(latitude).toString().toRequestBody(text),
longitude = FieldCoordinates.longitude(longitude).toString().toRequestBody(text),
accuracyM = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
operationId = operationId?.toRequestBody(text),
)
}
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
private suspend fun <T> cached(
key: String,
adapter: JsonAdapter<T>,
online: suspend () -> T,
): T {
return try {
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
} catch (error: IOException) {
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
@@ -281,11 +241,18 @@ class FieldFindingsRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session)
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
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
}
}
}
@@ -2,10 +2,10 @@ package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.korexlabs.dhinspeccion.data.offline.OfflineJsonCache
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -26,7 +26,6 @@ import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.io.IOException
import java.time.Instant
data class MobileActSummary(
@@ -38,7 +37,7 @@ data class MobileActSummary(
val title: String,
val summary: String,
val observations: String? = null,
val urgency: String? = null,
val urgency: String = "NON_URGENT",
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
@@ -63,7 +62,7 @@ data class MobileActDetail(
val title: String,
val summary: String,
val observations: String? = null,
val urgency: String? = null,
val urgency: String = "NON_URGENT",
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
@@ -93,21 +92,16 @@ data class MobileActListResponse(
)
data class CreateMobileActRequest(
val clientGeneratedId: String? = null,
val occurredAt: String,
val urgency: String,
val title: String,
val summary: String,
val observations: String? = null,
val assetIds: List<String>,
)
data class PrepareMobileActRequest(
val urgency: String,
)
data class UpdateMobileActRequest(
val assetIds: List<String>? = null,
val summary: String? = null,
val assetIds: List<String>,
)
data class MobileResponsibleRequest(
@@ -138,7 +132,7 @@ data class MobileActClosureHeader(
val code: String,
val status: String,
val visitId: String,
val urgency: String? = null,
val urgency: String = "NON_URGENT",
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
@@ -193,20 +187,11 @@ data class MobileActClosure(
val act: MobileActClosureHeader,
val visit: MobileVisitClosureHeader,
val responsible: MobileResponsible? = null,
val representativeSuggestion: MobileResponsible? = null,
val closure: MobileClosureRecord? = null,
val signatures: List<MobileSignature> = emptyList(),
val consents: MobileClosureConsents = MobileClosureConsents(),
)
data class MobileProfileSignatureStatus(
val configured: Boolean = false,
val mimeType: String? = null,
val sizeBytes: Int? = null,
val imageSha256: String? = null,
val updatedAt: String? = null,
)
data class MobileCompanyOutcomeRequest(
val status: String,
val reason: String,
@@ -214,7 +199,7 @@ data class MobileCompanyOutcomeRequest(
data class MobileSealActRequest(
val clientClosedAt: String = Instant.now().toString(),
val uploadMode: String = "IMMEDIATE",
val uploadMode: String = "ONLINE",
)
data class MobileCloseVisitRequest(
@@ -222,7 +207,7 @@ data class MobileCloseVisitRequest(
)
private interface MobileActsApi {
@GET("inspection-visits/{visitId}/acts/mobile")
@GET("inspection-visits/{visitId}/acts")
suspend fun listActs(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@@ -249,11 +234,6 @@ private interface MobileActsApi {
@Body request: UpdateMobileActRequest,
): MobileActDetail
@GET("users/self/signature")
suspend fun selfSignature(
@Header("Authorization") authorization: String,
): MobileProfileSignatureStatus
@GET("inspection-acts/{actId}/closure")
suspend fun closure(
@Header("Authorization") authorization: String,
@@ -271,7 +251,6 @@ private interface MobileActsApi {
suspend fun lock(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: PrepareMobileActRequest,
): MobileActClosure
@Multipart
@@ -330,14 +309,9 @@ private interface MobileActsApi {
}
class MobileActsRepository(context: Context) {
private val appContext = context.applicationContext
private val store = SecureSessionStore(appContext)
private val cache = OfflineJsonCache(appContext)
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val actListAdapter = moshi.adapter(MobileActListResponse::class.java)
private val actAdapter = moshi.adapter(MobileActDetail::class.java)
private val closureAdapter = moshi.adapter(MobileActClosure::class.java)
private val signatureStatusAdapter = moshi.adapter(MobileProfileSignatureStatus::class.java)
private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
@@ -345,40 +319,31 @@ class MobileActsRepository(context: Context) {
.build()
.create(MobileActsApi::class.java)
suspend fun list(visitId: String): MobileActListResponse =
cached("acts:$visitId", actListAdapter) {
authorized { session -> api.listActs("Bearer ${session.accessToken}", visitId) }
}
suspend fun list(visitId: String): MobileActListResponse = authorized { session ->
api.listActs("Bearer ${session.accessToken}", visitId)
}
suspend fun get(actId: String): MobileActDetail = cached("act:$actId", actAdapter) {
authorized { session -> api.act("Bearer ${session.accessToken}", actId) }
suspend fun get(actId: String): MobileActDetail = authorized { session ->
api.act("Bearer ${session.accessToken}", actId)
}
suspend fun create(
visitId: String,
assetId: String,
visitCode: String,
clientGeneratedId: String? = null,
occurredAt: String = Instant.now().toString(),
): MobileActDetail {
val created = authorized { session ->
api.createAct(
"Bearer ${session.accessToken}",
visitId,
CreateMobileActRequest(
clientGeneratedId = clientGeneratedId,
occurredAt = occurredAt,
title = "Acta de inspección $visitCode",
summary = "Se inició la inspección $visitCode.",
assetIds = emptyList(),
),
)
}
cache.put(userCacheKey("act:${created.id}"), actAdapter.toJson(created))
return created
}
suspend fun updateSummary(actId: String, summary: String): MobileActDetail = authorized { session ->
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(summary = summary))
urgency: String = "NON_URGENT",
): MobileActDetail = authorized { session ->
api.createAct(
"Bearer ${session.accessToken}",
visitId,
CreateMobileActRequest(
occurredAt = Instant.now().toString(),
urgency = urgency,
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOf(assetId),
),
)
}
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
@@ -390,25 +355,16 @@ class MobileActsRepository(context: Context) {
}
}
suspend fun closure(actId: String): MobileActClosure = cached("act-closure:$actId", closureAdapter) {
authorized { session -> api.closure("Bearer ${session.accessToken}", actId) }
suspend fun closure(actId: String): MobileActClosure = authorized { session ->
api.closure("Bearer ${session.accessToken}", actId)
}
suspend fun selfSignatureStatus(): MobileProfileSignatureStatus =
cached("self-signature-status", signatureStatusAdapter) {
authorized { session -> api.selfSignature("Bearer ${session.accessToken}") }
}
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure {
val value = authorized { session -> api.responsible("Bearer ${session.accessToken}", actId, request) }
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session ->
api.responsible("Bearer ${session.accessToken}", actId, request)
}
suspend fun lock(actId: String, urgency: String): MobileActClosure {
val value = authorized { session -> api.lock("Bearer ${session.accessToken}", actId, PrepareMobileActRequest(urgency)) }
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
suspend fun lock(actId: String): MobileActClosure = authorized { session ->
api.lock("Bearer ${session.accessToken}", actId)
}
suspend fun signInspector(
@@ -427,7 +383,6 @@ class MobileActsRepository(context: Context) {
accuracyM: Double?,
manifestation: String = "CONFORMITY",
statement: String? = null,
clientSignedAt: String = Instant.now().toString(),
): MobileActClosure = signature(
actId = actId,
png = png,
@@ -437,38 +392,22 @@ class MobileActsRepository(context: Context) {
company = true,
manifestation = manifestation,
statement = statement,
clientSignedAt = clientSignedAt,
)
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure {
val value = authorized { session ->
api.companyOutcome(
"Bearer ${session.accessToken}",
actId,
MobileCompanyOutcomeRequest(status, reason.trim()),
)
}
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session ->
api.companyOutcome(
"Bearer ${session.accessToken}",
actId,
MobileCompanyOutcomeRequest(status, reason.trim()),
)
}
suspend fun seal(
actId: String,
uploadMode: String = "IMMEDIATE",
clientClosedAt: String = Instant.now().toString(),
): MobileActClosure {
val value = authorized { session ->
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest(clientClosedAt, uploadMode))
}
cache.put(userCacheKey("act-closure:$actId"), closureAdapter.toJson(value))
return value
suspend fun seal(actId: String): MobileActClosure = authorized { session ->
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
}
suspend fun closeVisit(
visitId: String,
clientClosedAt: String = Instant.now().toString(),
): VisitDetail = authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest(clientClosedAt))
suspend fun closeVisit(visitId: String): VisitDetail = authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
}
private suspend fun signature(
@@ -480,7 +419,6 @@ class MobileActsRepository(context: Context) {
company: Boolean,
manifestation: String? = null,
statement: String? = null,
clientSignedAt: String = Instant.now().toString(),
): MobileActClosure = authorized { session ->
val text = "text/plain".toMediaType()
val file = MultipartBody.Part.createFormData(
@@ -489,11 +427,11 @@ class MobileActsRepository(context: Context) {
png.asRequestBody("image/png".toMediaType()),
)
val consent = "true".toRequestBody(text)
val signedAt = clientSignedAt.toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.let(FieldCoordinates::latitude)?.toString()?.toRequestBody(text)
val lon = longitude?.let(FieldCoordinates::longitude)?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.let(FieldCoordinates::accuracy)?.toString()?.toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, file, consent, signedAt,
@@ -509,20 +447,6 @@ class MobileActsRepository(context: Context) {
}
}
private fun userCacheKey(key: String): String = "${store.load()?.userId ?: "anonymous"}:$key"
private suspend fun <T> cached(
key: String,
adapter: JsonAdapter<T>,
online: suspend () -> T,
): T {
return try {
online().also { cache.put(userCacheKey(key), adapter.toJson(it)) }
} catch (error: IOException) {
cache.get(userCacheKey(key))?.let(adapter::fromJson) ?: throw error
}
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
@@ -530,11 +454,18 @@ class MobileActsRepository(context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session)
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
}
}
}
@@ -1,93 +0,0 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import 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 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)
return block(session)
}
private suspend fun refresh(previous: StoredSession): StoredSession =
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
}
@@ -1,32 +0,0 @@
package com.korexlabs.dhinspeccion.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import retrofit2.HttpException
/** One refresh lock for every API client sharing the encrypted session. */
internal object MobileSessionCoordinator {
private val mutex = Mutex()
suspend fun refresh(
previous: StoredSession,
load: () -> StoredSession?,
save: (MobileSessionResponse) -> StoredSession,
clear: () -> Unit,
request: suspend (RefreshRequest) -> MobileSessionResponse,
): StoredSession = mutex.withLock {
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
check(latest.userId == previous.userId) { "La sesión cambió. Volvé a abrir la operación." }
if (latest.refreshToken != previous.refreshToken) return@withLock latest
val response = try {
request(RefreshRequest(previous.refreshToken))
} catch (error: HttpException) {
// Network failures and server outages must not discard the saved login.
if (error.code() == 401 && load()?.refreshToken == previous.refreshToken) clear()
throw error
}
// A logout or another login during the request must never be undone.
check(load()?.refreshToken == previous.refreshToken) { "La sesión cambió. Volvé a ingresar." }
save(response)
}
}
@@ -1,64 +0,0 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.*
import java.time.LocalDate
import java.time.OffsetDateTime
internal data class TechnicalDefinition(
val id: String, val code: String, val name: String, val dataType: String,
val isRequired: Boolean = false, val isActive: Boolean = true,
val unit: String? = null, val options: List<String>? = null,
)
internal data class TechnicalValues(
val assetId: String, val family: FieldInventoryFamily,
val definitions: List<TechnicalDefinition>, val values: Map<String, Any?>,
)
internal data class TechnicalValuesRequest(val values: Map<String, Any?>)
private interface TechnicalApi {
@GET("assets/{id}/technical-values")
suspend fun get(@Header("Authorization") auth: String, @Path("id") id: String): TechnicalValues
@PUT("assets/{id}/technical-values")
suspend fun put(@Header("Authorization") auth: String, @Path("id") id: String, @Body body: TechnicalValuesRequest): TechnicalValues
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
internal class TechnicalRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val api = Retrofit.Builder().baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()))
.build().create(TechnicalApi::class.java)
suspend fun get(id: String) = authorized { api.get("Bearer ${it.accessToken}", id) }
suspend fun save(id: String, values: Map<String, Any?>) = authorized {
api.put("Bearer ${it.accessToken}", id, TechnicalValuesRequest(values))
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
val previous = store.load() ?: error("Sesión no iniciada")
try { return block(previous) }
catch (error: HttpException) { if (error.code() != 401) throw error }
val current = MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh)
return block(current)
}
}
/** Keeps IDs and typed JSON values identical to the Dashboard contract. */
internal fun technicalInputValue(definition: TechnicalDefinition, text: String): Any? {
val value = text.trim()
require(value.isNotBlank() || !definition.isRequired) { "Completá ${definition.name}." }
if (value.isBlank()) return null
fun invalid(): Nothing = throw IllegalArgumentException("Revisá el valor de ${definition.name}.")
return when (definition.dataType) {
"NUMBER" -> value.replace(',', '.').toDoubleOrNull()?.takeIf { it.isFinite() } ?: invalid()
"BOOLEAN" -> when (value) { "true" -> true; "false" -> false; else -> invalid() }
"DATE" -> runCatching { LocalDate.parse(value).toString() }.getOrElse { invalid() }
"DATETIME" -> runCatching { OffsetDateTime.parse(value).toInstant().toString() }.getOrElse { invalid() }
"SELECT" -> value.takeIf { it in definition.options.orEmpty() } ?: invalid()
else -> value.takeIf { it.length <= 4000 } ?: invalid()
}
}
@@ -1,632 +0,0 @@
package com.korexlabs.dhinspeccion.data.offline
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.korexlabs.dhinspeccion.data.CreateFieldFindingRequest
import com.korexlabs.dhinspeccion.data.CreateFieldInventoryRequest
import com.korexlabs.dhinspeccion.data.DhRepository
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.MobileActsRepository
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.SecureSessionStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
import retrofit2.HttpException
import java.io.File
import java.io.IOException
import java.time.Instant
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
@Entity(tableName = "pending_mobile_operations")
data class PendingMobileOperation(
@PrimaryKey val id: String,
val ownerUserId: String,
val type: String,
val resourceId: String,
val payloadJson: String,
val filePath: String? = null,
val state: String = "PENDING",
val attempts: Int = 0,
val lastError: String? = null,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
)
@Dao
interface PendingMobileOperationDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(value: PendingMobileOperation)
@Query("SELECT * FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR') ORDER BY createdAt ASC")
suspend fun pending(ownerUserId: String): List<PendingMobileOperation>
@Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND state IN ('PENDING','ERROR','SYNCING')")
suspend fun pendingCount(ownerUserId: String): Int
@Query("SELECT COUNT(*) FROM pending_mobile_operations WHERE ownerUserId=:ownerUserId AND resourceId=:resourceId AND type=:type AND state IN ('PENDING','ERROR','SYNCING')")
suspend fun hasPending(ownerUserId: String, type: String, resourceId: String): Int
@Query("UPDATE pending_mobile_operations SET state=:state, attempts=:attempts, lastError=:lastError, updatedAt=:updatedAt WHERE id=:id")
suspend fun mark(id: String, state: String, attempts: Int, lastError: String?, updatedAt: Long = System.currentTimeMillis())
@Query("DELETE FROM pending_mobile_operations WHERE id=:id")
suspend fun delete(id: String)
}
@Entity(tableName = "mobile_offline_cache")
data class OfflineCacheEntry(
@PrimaryKey val cacheKey: String,
val json: String,
val updatedAt: Long = System.currentTimeMillis(),
)
@Dao
interface OfflineCacheDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun put(value: OfflineCacheEntry)
@Query("SELECT * FROM mobile_offline_cache WHERE cacheKey=:key LIMIT 1")
suspend fun get(key: String): OfflineCacheEntry?
@Query("DELETE FROM mobile_offline_cache WHERE cacheKey=:key")
suspend fun delete(key: String)
}
@Database(entities = [PendingMobileOperation::class, OfflineCacheEntry::class], version = 1, exportSchema = false)
abstract class OfflineQueueDatabase : RoomDatabase() {
abstract fun operations(): PendingMobileOperationDao
abstract fun cache(): OfflineCacheDao
companion object {
@Volatile private var instance: OfflineQueueDatabase? = null
fun get(context: Context): OfflineQueueDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
OfflineQueueDatabase::class.java,
"dh_mobile_offline_queue.db",
).build().also { instance = it }
}
}
}
class OfflineJsonCache(context: Context) {
private val dao = OfflineQueueDatabase.get(context).cache()
suspend fun put(key: String, json: String) {
dao.put(OfflineCacheEntry(key, json))
}
suspend fun get(key: String): String? = dao.get(key)?.json
suspend fun delete(key: String) = dao.delete(key)
}
class OfflineMutationQueue(private val context: Context) {
private val dao = OfflineQueueDatabase.get(context).operations()
private val sessions = SecureSessionStore(context.applicationContext)
private val mediaRoot = File(context.filesDir, "offline-media").apply { mkdirs() }
suspend fun pendingCount(): Int = currentOwnerOrNull()?.let { dao.pendingCount(it) } ?: 0
suspend fun hasPending(type: String, resourceId: String): Boolean =
currentOwnerOrNull()?.let { dao.hasPending(it, type, resourceId) > 0 } ?: false
suspend fun enqueueFindingCreate(
findingId: String,
visitId: String,
assetId: String,
request: CreateFieldFindingRequest,
): String = enqueueJson(
type = "FINDING_CREATE",
resourceId = findingId,
payload = JSONObject()
.put("visitId", visitId)
.put("assetId", assetId)
.put("actId", request.actId)
.put("clientGeneratedId", request.clientGeneratedId ?: findingId)
.put("catalogItemId", request.catalogItemId)
.put("customTitle", request.customTitle)
.put("customLegalBasis", request.customLegalBasis)
.put("description", request.description)
.put("severity", request.severity)
.toString(),
)
suspend fun enqueueFindingPhoto(
findingId: String,
source: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
title: String?,
description: String?,
capturedAt: String = Instant.now().toString(),
): String = persistWithFile("FINDING_PHOTO", findingId, source, "jpg") { id ->
JSONObject()
.put("operationId", id)
.put("latitude", latitude)
.put("longitude", longitude)
.put("accuracyM", accuracyM)
.put("title", title)
.put("description", description)
.put("capturedAt", capturedAt)
.toString()
}
suspend fun enqueueVisitStart(visitId: String): String = enqueueJson(
type = "VISIT_START",
resourceId = visitId,
payload = JSONObject().toString(),
)
suspend fun enqueueVisitClose(
visitId: String,
clientClosedAt: String = Instant.now().toString(),
): String = enqueueJson(
type = "VISIT_CLOSE",
resourceId = visitId,
payload = JSONObject().put("clientClosedAt", clientClosedAt).toString(),
)
suspend fun enqueueFieldAssetCreate(
assetId: String,
visitId: String,
request: CreateFieldInventoryRequest,
): String = enqueueJson(
type = "FIELD_ASSET_CREATE",
resourceId = assetId,
payload = JSONObject()
.put("visitId", visitId)
.put("typeId", request.typeId)
.put("parentId", request.parentId)
.put("familyId", request.familyId)
.put("code", request.code)
.put("name", request.name)
.put("commonName", request.commonName)
.put("description", request.description)
.put("discoveryNotes", request.discoveryNotes)
.put("attributes", JSONObject(request.attributes))
.put("deviceLatitude", request.deviceLatitude)
.put("deviceLongitude", request.deviceLongitude)
.put("deviceAccuracyM", request.deviceAccuracyM)
.put("deviceCapturedAt", request.deviceCapturedAt)
.put("deviceLabel", request.deviceLabel)
.toString(),
)
suspend fun enqueueFieldAssetPhoto(
assetId: String,
visitId: String,
source: File,
latitude: Double,
longitude: Double,
accuracyM: Double?,
capturedAt: String = Instant.now().toString(),
): String = persistWithFile("FIELD_ASSET_PHOTO", assetId, source, "jpg") { id ->
JSONObject()
.put("visitId", visitId)
.put("operationId", id)
.put("latitude", latitude)
.put("longitude", longitude)
.put("accuracyM", accuracyM)
.put("capturedAt", capturedAt)
.toString()
}
suspend fun enqueueFieldAssetSelect(visitId: String, assetId: String): String = enqueueJson(
type = "FIELD_ASSET_SELECT",
resourceId = assetId,
payload = JSONObject().put("visitId", visitId).toString(),
)
suspend fun enqueueActCreate(
actId: String,
visitId: String,
visitCode: String,
occurredAt: String,
): String = enqueueJson(
type = "ACT_CREATE",
resourceId = actId,
payload = JSONObject()
.put("visitId", visitId)
.put("visitCode", visitCode)
.put("occurredAt", occurredAt)
.toString(),
)
suspend fun enqueueActSummary(actId: String, summary: String): String = enqueueJson(
type = "ACT_SUMMARY",
resourceId = actId,
payload = JSONObject().put("summary", summary).toString(),
)
suspend fun enqueueActAsset(actId: String, assetId: String): String = enqueueJson(
type = "ACT_ASSET_ENSURE",
resourceId = actId,
payload = JSONObject().put("assetId", assetId).toString(),
)
suspend fun enqueueResponsible(actId: String, request: MobileResponsibleRequest): String = enqueueJson(
type = "ACT_RESPONSIBLE",
resourceId = actId,
payload = JSONObject()
.put("attendanceStatus", request.attendanceStatus)
.put("fullName", request.fullName)
.put("documentType", request.documentType)
.put("documentNumber", request.documentNumber)
.put("position", request.position)
.put("email", request.email)
.put("phone", request.phone)
.put("absenceReason", request.absenceReason)
.toString(),
)
suspend fun enqueueActLock(actId: String, urgency: String): String = enqueueJson(
type = "ACT_LOCK",
resourceId = actId,
payload = JSONObject().put("urgency", urgency).toString(),
)
suspend fun enqueueCompanyOutcome(actId: String, status: String, reason: String): String = enqueueJson(
type = "COMPANY_OUTCOME",
resourceId = actId,
payload = JSONObject().put("status", status).put("reason", reason).toString(),
)
suspend fun enqueueCompanySignature(
actId: String,
source: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
manifestation: String,
statement: String?,
clientSignedAt: String = Instant.now().toString(),
): String = persistWithFile("COMPANY_SIGNATURE", actId, source, "png") { _ ->
JSONObject()
.put("latitude", latitude)
.put("longitude", longitude)
.put("accuracyM", accuracyM)
.put("manifestation", manifestation)
.put("statement", statement)
.put("clientSignedAt", clientSignedAt)
.toString()
}
suspend fun enqueueSeal(
actId: String,
clientClosedAt: String = Instant.now().toString(),
): String {
val id = UUID.randomUUID().toString()
dao.insert(
PendingMobileOperation(
id = id,
ownerUserId = currentOwner(),
type = "ACT_SEAL",
resourceId = actId,
payloadJson = JSONObject().put("clientClosedAt", clientClosedAt).toString(),
createdAt = nextCreatedAt(),
),
)
schedule(context)
return id
}
private suspend fun enqueueJson(type: String, resourceId: String, payload: String): String {
val id = UUID.randomUUID().toString()
dao.insert(PendingMobileOperation(
id = id,
ownerUserId = currentOwner(),
type = type,
resourceId = resourceId,
payloadJson = payload,
createdAt = nextCreatedAt(),
))
schedule(context)
return id
}
private suspend fun persistWithFile(
type: String,
resourceId: String,
source: File,
extension: String,
payload: (String) -> String,
): String = withContext(Dispatchers.IO) {
val id = UUID.randomUUID().toString()
val target = File(mediaRoot, "$id.$extension")
source.copyTo(target, overwrite = false)
dao.insert(
PendingMobileOperation(
id = id,
ownerUserId = currentOwner(),
type = type,
resourceId = resourceId,
payloadJson = payload(id),
filePath = target.absolutePath,
createdAt = nextCreatedAt(),
),
)
source.delete()
schedule(context)
id
}
suspend fun syncAll(): SyncResult = syncMutex.withLock {
val ownerUserId = currentOwnerOrNull() ?: return@withLock SyncResult(0, 0, retry = false)
var synced = 0
var lastError: String? = null
for (operation in dao.pending(ownerUserId)) {
val attempt = operation.attempts + 1
dao.mark(operation.id, "SYNCING", attempt, null)
try {
syncOne(operation)
operation.filePath?.let { File(it).delete() }
dao.delete(operation.id)
synced += 1
} catch (error: IOException) {
dao.mark(operation.id, "PENDING", attempt, error.message)
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = true, lastError = error.message)
} catch (error: HttpException) {
if (isAlreadyApplied(operation, error)) {
operation.filePath?.let { File(it).delete() }
dao.delete(operation.id)
synced += 1
} else {
lastError = httpError(error)
dao.mark(operation.id, "ERROR", attempt, lastError)
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
}
} catch (error: Throwable) {
lastError = error.message ?: error::class.java.simpleName
dao.mark(operation.id, "ERROR", attempt, lastError)
return@withLock SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
}
}
SyncResult(synced, dao.pendingCount(ownerUserId), retry = false, lastError = lastError)
}
private suspend fun syncOne(operation: PendingMobileOperation) {
val payload = JSONObject(operation.payloadJson)
when (operation.type) {
"VISIT_START" -> DhRepository(context).startVisit(operation.resourceId)
"VISIT_CLOSE" -> MobileActsRepository(context).closeVisit(
operation.resourceId,
payload.getString("clientClosedAt"),
)
"FIELD_ASSET_CREATE" -> DhRepository(context).createFieldAsset(
payload.getString("visitId"),
CreateFieldInventoryRequest(
clientGeneratedId = operation.resourceId,
typeId = payload.getString("typeId"),
parentId = payload.optNullableString("parentId"),
familyId = payload.optNullableString("familyId"),
code = payload.optNullableString("code"),
name = payload.getString("name"),
commonName = payload.optNullableString("commonName"),
description = payload.optNullableString("description"),
discoveryNotes = payload.optNullableString("discoveryNotes"),
attributes = jsonObjectToMap(payload.getJSONObject("attributes")),
deviceLatitude = payload.getDouble("deviceLatitude"),
deviceLongitude = payload.getDouble("deviceLongitude"),
deviceAccuracyM = payload.optDoubleOrNull("deviceAccuracyM"),
deviceCapturedAt = payload.getString("deviceCapturedAt"),
deviceLabel = payload.optString("deviceLabel", "DH Android"),
),
)
"FIELD_ASSET_PHOTO" -> DhRepository(context).uploadFieldPhoto(
visitId = payload.getString("visitId"),
assetId = operation.resourceId,
file = requiredFile(operation),
latitude = payload.getDouble("latitude"),
longitude = payload.getDouble("longitude"),
accuracyM = payload.optDoubleOrNull("accuracyM"),
capturedAt = payload.getString("capturedAt"),
operationId = payload.getString("operationId"),
)
"FIELD_ASSET_SELECT" -> DhRepository(context).selectFieldAsset(
payload.getString("visitId"),
operation.resourceId,
)
"ACT_CREATE" -> MobileActsRepository(context).create(
visitId = payload.getString("visitId"),
visitCode = payload.getString("visitCode"),
clientGeneratedId = operation.resourceId,
occurredAt = payload.getString("occurredAt"),
)
"ACT_SUMMARY" -> MobileActsRepository(context).updateSummary(
operation.resourceId,
payload.getString("summary"),
)
"ACT_ASSET_ENSURE" -> MobileActsRepository(context).ensureAsset(
operation.resourceId,
payload.getString("assetId"),
)
"FINDING_CREATE" -> FieldFindingsRepository(context).create(
payload.getString("visitId"),
payload.getString("assetId"),
CreateFieldFindingRequest(
actId = payload.getString("actId"),
clientGeneratedId = payload.getString("clientGeneratedId"),
catalogItemId = payload.optNullableString("catalogItemId"),
customTitle = payload.optNullableString("customTitle"),
customLegalBasis = payload.optNullableString("customLegalBasis"),
description = payload.getString("description"),
severity = if (!payload.has("severity") || payload.isNull("severity")) null else payload.getInt("severity"),
),
)
"ACT_RESPONSIBLE" -> MobileActsRepository(context).setResponsible(
operation.resourceId,
MobileResponsibleRequest(
attendanceStatus = payload.getString("attendanceStatus"),
fullName = payload.optNullableString("fullName"),
documentType = payload.optNullableString("documentType"),
documentNumber = payload.optNullableString("documentNumber"),
position = payload.optNullableString("position"),
email = payload.optNullableString("email"),
phone = payload.optNullableString("phone"),
absenceReason = payload.optNullableString("absenceReason"),
),
)
"ACT_LOCK" -> MobileActsRepository(context).lock(
operation.resourceId,
payload.getString("urgency"),
)
"COMPANY_OUTCOME" -> MobileActsRepository(context).companyOutcome(
operation.resourceId,
payload.getString("status"),
payload.getString("reason"),
)
"FINDING_PHOTO" -> {
val file = requiredFile(operation)
FieldFindingsRepository(context).uploadObservationPhoto(
findingId = operation.resourceId,
file = file,
latitude = payload.getDouble("latitude"),
longitude = payload.getDouble("longitude"),
accuracyM = payload.optDoubleOrNull("accuracyM"),
title = payload.optNullableString("title"),
description = payload.optNullableString("description"),
capturedAt = payload.getString("capturedAt"),
operationId = payload.getString("operationId"),
)
}
"COMPANY_SIGNATURE" -> {
val file = requiredFile(operation)
MobileActsRepository(context).signCompany(
actId = operation.resourceId,
png = file,
latitude = payload.optDoubleOrNull("latitude"),
longitude = payload.optDoubleOrNull("longitude"),
accuracyM = payload.optDoubleOrNull("accuracyM"),
manifestation = payload.optString("manifestation", "CONFORMITY"),
statement = payload.optNullableString("statement"),
clientSignedAt = payload.getString("clientSignedAt"),
)
}
"ACT_SEAL" -> MobileActsRepository(context).seal(
actId = operation.resourceId,
uploadMode = "DEFERRED",
clientClosedAt = payload.getString("clientClosedAt"),
)
else -> error("Operación offline desconocida: ${operation.type}")
}
}
private suspend fun isAlreadyApplied(operation: PendingMobileOperation, error: HttpException): Boolean {
if (error.code() != 409) return false
return when (operation.type) {
"VISIT_START" -> runCatching {
DhRepository(context).visit(operation.resourceId).status in setOf("IN_PROGRESS", "CLOSED")
}.getOrDefault(false)
"VISIT_CLOSE" -> runCatching {
DhRepository(context).visit(operation.resourceId).status == "CLOSED"
}.getOrDefault(false)
"ACT_LOCK" -> runCatching {
MobileActsRepository(context).get(operation.resourceId).status != "DRAFT"
}.getOrDefault(false)
"COMPANY_OUTCOME", "COMPANY_SIGNATURE" -> runCatching {
MobileActsRepository(context).closure(operation.resourceId).signatures.any {
it.signerType == "COMPANY_RESPONSIBLE" && (it.status == "SIGNED" || it.status == "REFUSED")
}
}.getOrDefault(false)
"ACT_SEAL" -> runCatching {
MobileActsRepository(context).get(operation.resourceId).status == "SEALED"
}.getOrDefault(false)
else -> false
}
}
private fun requiredFile(operation: PendingMobileOperation): File {
val path = operation.filePath ?: error("La operación ${operation.id} no tiene archivo local")
return File(path).also { require(it.isFile) { "Archivo local pendiente inexistente" } }
}
private fun currentOwnerOrNull(): String? = sessions.load()?.userId
private fun currentOwner(): String = currentOwnerOrNull()
?: throw IllegalStateException("Sesión no iniciada")
data class SyncResult(val synced: Int, val pending: Int, val retry: Boolean, val lastError: String? = null)
companion object {
private const val UNIQUE_WORK = "dh-mobile-offline-sync"
private val syncMutex = Mutex()
private val queueClock = AtomicLong(System.currentTimeMillis())
private fun nextCreatedAt(): Long = queueClock.updateAndGet { previous ->
maxOf(System.currentTimeMillis(), previous + 1)
}
fun schedule(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<OfflineSyncWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(UNIQUE_WORK, ExistingWorkPolicy.KEEP, request)
}
}
}
class OfflineSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val result = OfflineMutationQueue(applicationContext).syncAll()
return if (result.retry) Result.retry() else Result.success()
}
}
private fun httpError(error: HttpException): String {
val body = runCatching { error.response()?.errorBody()?.string() }.getOrNull().orEmpty()
return runCatching { JSONObject(body).optString("message") }.getOrNull()
?.takeIf { it.isNotBlank() }
?: "No se pudo sincronizar (HTTP ${error.code()})"
}
private fun jsonObjectToMap(value: JSONObject): Map<String, Any?> = buildMap {
val keys = value.keys()
while (keys.hasNext()) {
val key = keys.next()
put(key, jsonValue(value.opt(key)))
}
}
private fun jsonValue(value: Any?): Any? = when (value) {
null, JSONObject.NULL -> null
is JSONObject -> jsonObjectToMap(value)
is org.json.JSONArray -> List(value.length()) { index -> jsonValue(value.opt(index)) }
else -> value
}
private fun JSONObject.optDoubleOrNull(name: String): Double? =
if (!has(name) || isNull(name)) null else getDouble(name)
private fun JSONObject.optNullableString(name: String): String? =
if (!has(name) || isNull(name)) null else optString(name).takeIf { it.isNotBlank() }
@@ -60,7 +60,6 @@ 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.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
import kotlinx.coroutines.launch
@@ -522,7 +521,18 @@ private fun InventoryCard(item: FieldInventoryItem, canModify: Boolean, onSelect
}
private fun buildAttributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
fieldAttributePayload(type.attributes, values)
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceAttribute(definition, raw)
}.toMap()
private fun coerceAttribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun hasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -537,17 +547,13 @@ private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellab
}
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(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -1,73 +0,0 @@
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,
)
}
@@ -1,962 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Environment
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.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.fieldAttributePayload
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("Tipo de instalación *", fontWeight = FontWeight.Bold)
OutlinedTextField(
value = familySearch,
onValueChange = { familySearch = it },
label = { Text("Buscar tipo de instalació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?> =
fieldAttributePayload(type.attributes, values)
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)
@@ -57,7 +57,6 @@ 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.fieldAttributePayload
import com.korexlabs.dhinspeccion.data.VisitDetail
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -87,10 +86,7 @@ fun F3VisitRoot(model: MainViewModel) {
inventoryMode = true
},
)
inventoryMode -> F3FieldInventoryScreen(model, onBack = {
inventoryMode = false
actsMode = true
})
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
else -> F3VisitOverview(
model = model,
onInventory = { inventoryMode = true },
@@ -123,9 +119,6 @@ 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)
}
@@ -145,10 +138,10 @@ private fun F3VisitOverview(
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, pasás directamente a las Actas de esta inspección.")
Text("La inspección todavía no comenzó", fontWeight = FontWeight.Bold)
Text("Iniciarla registra fecha/hora real y tu usuario como actor de campo.")
Button(
onClick = { model.startVisit(); onActs() },
onClick = { model.startVisit() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(if (model.busy) "Iniciando…" else "Iniciar inspección") }
@@ -157,12 +150,12 @@ private fun F3VisitOverview(
}
if (visit.status == "IN_PROGRESS") {
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 ""}")
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
OutlinedButton(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Inventario / Hallazgos")
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
@@ -170,7 +163,7 @@ private fun F3VisitOverview(
}
} else if (visit.status == "PLANNED") {
Text(
"Primero iniciá la inspección para habilitar Actas, Hallazgos y altas de campo.",
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
}
@@ -216,9 +209,7 @@ 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(visit.scopeAsset?.name ?: "Yacimiento de la inspección")
}
var parentLabel by rememberSaveable(visit.id) { mutableStateOf("Área de la inspección") }
var name by rememberSaveable(visit.id) { mutableStateOf("") }
var commonName by rememberSaveable(visit.id) { mutableStateOf("") }
var selectedTypeId by rememberSaveable(visit.id) { mutableStateOf<String?>(null) }
@@ -332,10 +323,10 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onBack) { Text("Acta") }
OutlinedButton(onClick = onBack) { Text("Volver") }
Column(horizontalAlignment = Alignment.End) {
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Elegí una Instalación o Subinstalación", style = MaterialTheme.typography.bodySmall)
Text("Inventario de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Área → Yacimiento → Instalación Subinstalación", style = MaterialTheme.typography.bodySmall)
}
}
Column(Modifier.padding(horizontal = 16.dp)) { F3MessageStrip(model) }
@@ -419,182 +410,170 @@ private fun F3FieldInventoryScreen(model: MainViewModel, onBack: () -> Unit) {
}
}
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") }
}
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar por nombre o código") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = { model.searchInventory(search) }, enabled = !model.busy) { Text("Buscar") }
}
Row(
Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Alta en campo", fontWeight = FontWeight.Bold)
Text("Padre: $parentLabel", style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(onClick = {
showCreate = !showCreate
if (showCreate) model.loadFieldTypes(parentId)
}) { Text(if (showCreate) "Ocultar" else "Agregar") }
}
if (showCreate) {
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("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("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,
)
}
}
}
OutlinedTextField(
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(),
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
}
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = {
if (f3HasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar alta y capturar GPS") }
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
.weight(1f, fill = false),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (parentId != null) {
OutlinedButton(onClick = {
parentId = null
parentLabel = "Área de la inspección"
resetCreateForm()
selectedTypeId = null
model.loadFieldTypes(null)
}) { Text("Volver al Área") }
}
if (model.fieldTypes.isEmpty()) {
Text("Este nivel no admite más hijos estructurales.")
} else {
Text("Vas a crear", style = MaterialTheme.typography.bodySmall)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(model.fieldTypes, key = { it.id }) { type ->
AssistChip(
onClick = {
selectedTypeId = type.id
selectedFamilyId = null
attributeValues.clear()
},
label = { Text(if (type.id == selectedTypeId) "${type.name}" else type.name) },
)
}
}
}
if (selectedType?.familyRequired == true) {
Text("Familia técnica", fontWeight = FontWeight.Bold)
Text(
"Elegí la que corresponda al Excel. Si no existe, usá Otro / no catalogado; nunca quedás bloqueado.",
style = MaterialTheme.typography.bodySmall,
)
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedType.families, key = { it.id }) { family ->
AssistChip(
onClick = { selectedFamilyId = family.id },
label = {
val prefix = when {
selectedFamilyId == family.id -> ""
family.isOther -> "+ "
else -> ""
}
Text(prefix + family.name)
},
)
}
}
selectedFamily?.let { family ->
if (family.isOther) {
Text(
"Se registrará como familia no catalogada para revisión posterior en oficina.",
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall,
)
}
if (family.informationLabels.isNotEmpty()) {
Text(
"Información esperada: ${family.informationLabels.joinToString(" · ")}",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Nombre identificable *") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = commonName,
onValueChange = { commonName = it },
label = { Text("Nombre habitual") },
modifier = Modifier.fillMaxWidth(),
)
selectedType?.attributes?.forEach { definition ->
OutlinedTextField(
value = attributeValues[definition.code].orEmpty(),
onValueChange = { attributeValues[definition.code] = it },
label = { Text(definition.name + if (definition.isRequired) " *" else "") },
supportingText = {
val details = listOfNotNull(definition.unit, definition.options?.toString()).joinToString(" · ")
if (details.isNotBlank()) Text(details)
},
keyboardOptions = KeyboardOptions(
keyboardType = if (
definition.dataType.uppercase() in setOf("NUMBER", "DECIMAL", "INTEGER", "FLOAT")
) KeyboardType.Decimal else KeyboardType.Text,
),
modifier = Modifier.fillMaxWidth(),
)
}
val attributesReady = selectedType?.attributes
?.filter { it.isRequired }
?.all { attributeValues[it.code].orEmpty().isNotBlank() }
?: false
val familyReady = selectedType?.familyRequired != true || selectedFamilyId != null
Button(
onClick = {
if (f3HasLocation(context)) createWithLocation()
else locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
},
enabled = selectedType != null && name.isNotBlank() && attributesReady && familyReady && !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Capturar GPS y crear") }
HorizontalDivider()
}
}
Text(
"Instalaciones y subinstalaciones",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontWeight = FontWeight.Bold,
)
Text("Estructura disponible", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), fontWeight = FontWeight.Bold)
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
model.inventory.filter { candidate ->
candidate.type?.let(::f3TypeCode) in setOf("instalacion", "subinstalacion")
},
key = { it.id },
) { item ->
items(model.inventory, key = { it.id }) { item ->
F3InventoryCard(
item = item,
onInspect = { model.selectExisting(item) },
@@ -632,10 +611,10 @@ private fun F3CaptureCard(
if (captureRequired) {
Text("GPS de alta: ${if (gps) "OK" else "pendiente"} · Fotos: $photos")
if (!ready) {
Text("Antes de registrar el Hallazgo, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Text("Antes de registrar Hallazgos, completá GPS + foto.", color = MaterialTheme.colorScheme.error)
Button(onClick = onPhoto, modifier = Modifier.fillMaxWidth()) { Text("Tomar foto obligatoria") }
} else {
Text("Captura completa · listo para el Hallazgo", color = MaterialTheme.colorScheme.primary)
Text("Captura completa · listo para Hallazgos", color = MaterialTheme.colorScheme.primary)
}
} else {
Text("Registro existente seleccionado.", style = MaterialTheme.typography.bodySmall)
@@ -652,8 +631,8 @@ private fun F3InventoryCard(
) {
val typeCode = item.type?.let(::f3TypeCode).orEmpty()
val canHaveFinding = typeCode in setOf("instalacion", "subinstalacion")
val canHaveChild = typeCode == "instalacion"
val childLabel = "+ Agregar subinstalación"
val canHaveChild = typeCode in setOf("yacimiento", "instalacion")
val childLabel = if (typeCode == "yacimiento") "Agregar instalación aquí" else "Agregar subinstalación aquí"
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
@@ -667,12 +646,12 @@ private fun F3InventoryCard(
}
if (canHaveFinding) {
Text(
if (item.readyForFinding) "Disponible" else "GPS/foto pendiente",
if (item.readyForFinding) "Disponible para Hallazgos" else "GPS/foto pendiente",
color = if (item.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
Button(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Seleccionar para Hallazgo" else "Usar para Hallazgo")
OutlinedButton(onClick = onInspect, modifier = Modifier.fillMaxWidth()) {
Text(if (item.selectedInInspection) "Abrir Hallazgos" else "Usar en esta inspección")
}
}
if (canHaveChild) {
@@ -721,7 +700,18 @@ private fun statusLabel(status: String): String = when (status) {
}
private fun buildF3Attributes(type: FieldType, values: Map<String, String>): Map<String, Any?> =
fieldAttributePayload(type.attributes, values)
type.attributes.mapNotNull { definition ->
val raw = values[definition.code]?.trim().orEmpty()
if (raw.isBlank()) return@mapNotNull null
definition.code to coerceF3Attribute(definition, raw)
}.toMap()
private fun coerceF3Attribute(definition: FieldAttributeDefinition, raw: String): Any = when (definition.dataType.uppercase()) {
"INTEGER", "INT" -> raw.toLongOrNull() ?: raw
"NUMBER", "DECIMAL", "FLOAT", "DOUBLE" -> raw.replace(',', '.').toDoubleOrNull() ?: raw
"BOOLEAN", "BOOL" -> raw.lowercase() in setOf("true", "1", "si", "", "yes")
else -> raw
}
private fun f3HasPermission(context: Context, permission: String): Boolean =
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
@@ -737,20 +727,16 @@ private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCance
}
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(F3GeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
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) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -1,35 +1,27 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.graphics.BitmapFactory
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.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -42,17 +34,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.exifinterface.media.ExifInterface
@@ -75,32 +60,25 @@ private data class FindingGeoSnapshot(
val accuracyM: Double?,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FieldFindingScreen(model: MainViewModel) {
val options = model.fieldFindingOptions ?: return
val asset = model.selectedFieldAsset?.asset ?: return
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
var search by rememberSaveable(asset.id) { mutableStateOf("") }
var catalogExpanded by rememberSaveable(asset.id) { mutableStateOf(false) }
var selectedCatalogId by rememberSaveable(asset.id) { mutableStateOf<String?>(null) }
var other by rememberSaveable(asset.id) { mutableStateOf(false) }
var customTitle by rememberSaveable(asset.id) { mutableStateOf("") }
var customLegalBasis by rememberSaveable(asset.id) { mutableStateOf("") }
var description by rememberSaveable(asset.id) { mutableStateOf("") }
var severityText by rememberSaveable(asset.id) { mutableStateOf("") }
var correctionDueOn by rememberSaveable(asset.id) { mutableStateOf("") }
var localError by rememberSaveable { mutableStateOf<String?>(null) }
var requestedFindingId by remember { mutableStateOf<String?>(null) }
var pendingPhotoFile by remember { mutableStateOf<File?>(null) }
var pendingPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
var pendingPhotoFindingId by remember { mutableStateOf<String?>(null) }
var reviewPhotoFile by remember { mutableStateOf<File?>(null) }
var reviewPhotoGeo by remember { mutableStateOf<FindingGeoSnapshot?>(null) }
var reviewPhotoFindingId by remember { mutableStateOf<String?>(null) }
val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val file = pendingPhotoFile
@@ -108,11 +86,14 @@ fun FieldFindingScreen(model: MainViewModel) {
val findingId = pendingPhotoFindingId
if (success && file != null && geo != null && findingId != null) {
runCatching { writeFindingExif(file, geo) }
reviewPhotoFile = file
reviewPhotoGeo = geo
reviewPhotoFindingId = findingId
} else {
file?.delete()
model.uploadFindingPhoto(
findingId = findingId,
file = file,
latitude = geo.latitude,
longitude = geo.longitude,
accuracyM = geo.accuracyM,
title = "Evidencia fotográfica de campo",
)
}
pendingPhotoFile = null
pendingPhotoGeo = null
@@ -129,7 +110,6 @@ fun FieldFindingScreen(model: MainViewModel) {
pendingPhotoFindingId = findingId
takePicture.launch(uri)
}
.onFailure { localError = it.message ?: "No se pudo capturar la ubicación GPS." }
}
}
@@ -142,7 +122,6 @@ fun FieldFindingScreen(model: MainViewModel) {
val findingId = requestedFindingId
requestedFindingId = null
if (camera && location && findingId != null) beginPhoto(findingId)
else localError = "Permití cámara y ubicación para adjuntar una foto con GPS."
}
fun requestPhoto(findingId: String) {
@@ -182,81 +161,8 @@ fun FieldFindingScreen(model: MainViewModel) {
customLegalBasis = ""
description = ""
severityText = ""
correctionDueOn = ""
search = ""
catalogExpanded = false
}
}
val reviewFile = reviewPhotoFile
val reviewGeo = reviewPhotoGeo
val reviewFindingId = reviewPhotoFindingId
if (reviewFile != null && reviewGeo != null && reviewFindingId != null) {
val bitmap = remember(reviewFile.absolutePath, reviewFile.lastModified()) {
runCatching { BitmapFactory.decodeFile(reviewFile.absolutePath)?.asImageBitmap() }.getOrNull()
}
Dialog(onDismissRequest = {}) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Revisar fotografía", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
"Todavía no se subió. Verificá la imagen antes de incorporarla al Hallazgo.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = "Vista previa de la fotografía del Hallazgo",
modifier = Modifier.fillMaxWidth().heightIn(min = 220.dp, max = 460.dp),
contentScale = ContentScale.Fit,
)
} else {
Text("No se pudo generar la vista previa.", color = MaterialTheme.colorScheme.error)
}
Text(
"GPS ${"%.6f".format(reviewGeo.latitude)}, ${"%.6f".format(reviewGeo.longitude)}${reviewGeo.accuracyM?.let { " · ±${"%.1f".format(it)} m" }.orEmpty()}",
style = MaterialTheme.typography.bodySmall,
)
Button(
onClick = {
model.uploadFindingPhoto(
findingId = reviewFindingId,
file = reviewFile,
latitude = reviewGeo.latitude,
longitude = reviewGeo.longitude,
accuracyM = reviewGeo.accuracyM,
title = "Evidencia fotográfica de campo",
)
reviewPhotoFile = null
reviewPhotoGeo = null
reviewPhotoFindingId = null
},
enabled = !model.busy && bitmap != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Usar esta foto") }
OutlinedButton(
onClick = {
reviewFile.delete()
reviewPhotoFile = null
reviewPhotoGeo = null
reviewPhotoFindingId = null
beginPhoto(reviewFindingId)
},
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Volver a tomar") }
OutlinedButton(
onClick = {
reviewFile.delete()
reviewPhotoFile = null
reviewPhotoGeo = null
reviewPhotoFindingId = null
},
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Eliminar foto") }
}
}
}
}
@@ -271,22 +177,21 @@ fun FieldFindingScreen(model: MainViewModel) {
OutlinedButton(onClick = { model.clearFindingFlow() }, enabled = !model.busy) {
Text("Volver")
}
Text("Nuevo Hallazgo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Hallazgo de campo", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(asset.name, fontWeight = FontWeight.Bold)
Text("${asset.code} · ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text("${asset.code} · Acta ${options.act.code}", style = MaterialTheme.typography.bodySmall)
Text(
if (options.capture.readyForFinding) "Inventario listo para registrar Hallazgos" else "Completá GPS y foto antes de continuar",
"GPS + foto del Inventario: ${if (options.capture.readyForFinding) "OK" else "pendiente"}",
color = if (options.capture.readyForFinding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
localError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
model.error?.let {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Text(it, Modifier.padding(12.dp))
@@ -305,7 +210,7 @@ fun FieldFindingScreen(model: MainViewModel) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${finding.code} · ${finding.title}", fontWeight = FontWeight.SemiBold)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${findingStatusLabelEs(finding.status)}", style = MaterialTheme.typography.bodySmall)
Text("Gravedad: ${finding.severity ?: "s/d"} · ${finding.status}", style = MaterialTheme.typography.bodySmall)
Text(
"Evidencias: ${evidence.size} · Fotos: ${evidence.count { it.kind == "PHOTO" }}",
style = MaterialTheme.typography.bodySmall,
@@ -316,123 +221,87 @@ fun FieldFindingScreen(model: MainViewModel) {
style = MaterialTheme.typography.bodySmall,
)
}
val photoCount = evidence.count { it.kind == "PHOTO" }
Button(
onClick = { requestPhoto(finding.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (photoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
Text("Tomar foto con GPS")
}
Text(
if (photoCount == 0) "Podés adjuntar una o varias fotos." else "$photoCount foto${if (photoCount == 1) "" else "s"} adjunta${if (photoCount == 1) "" else "s"}. Podés seguir agregando.",
style = MaterialTheme.typography.bodySmall,
)
}
}
}
HorizontalDivider()
}
Text("1. Tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("1. Elegí el tipo de Hallazgo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (!options.catalog.typeConfigured) {
Text(
"No hay tipos sugeridos para este Inventario. Elegí Otro para cargarlo manualmente.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
options.catalog.configurationReason
?: "Este tipo de Inventario todavía no tiene un catálogo contextual configurado. Podés usar OTROS.",
color = MaterialTheme.colorScheme.secondary,
)
}
ExposedDropdownMenuBox(
expanded = catalogExpanded && !model.busy,
onExpandedChange = {
if (!model.busy) {
catalogExpanded = it
if (it) search = ""
}
},
) {
OutlinedTextField(
value = if (catalogExpanded) search else when {
other -> "Otro / No está en la lista"
selected != null -> selected.title
else -> ""
OutlinedTextField(
value = search,
onValueChange = { search = it },
label = { Text("Buscar en catálogo") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
filtered.forEach { item ->
val chosen = !other && selectedCatalogId == item.id
Card(
modifier = Modifier.fillMaxWidth().clickable {
selectedCatalogId = item.id
other = false
severityText = item.suggestedSeverity?.toString().orEmpty()
},
colors = if (chosen) {
CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
} else {
CardDefaults.cardColors()
},
onValueChange = { search = it; catalogExpanded = true },
label = { Text("Tipo de Hallazgo *") },
placeholder = { Text("Buscar o seleccionar") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = catalogExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryEditable),
enabled = !model.busy,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = {
catalogExpanded = false
focusManager.moveFocus(FocusDirection.Down)
}),
)
ExposedDropdownMenu(
expanded = catalogExpanded && !model.busy,
onDismissRequest = { catalogExpanded = false; search = "" },
modifier = Modifier.heightIn(max = 320.dp),
) {
filtered.forEach { item ->
DropdownMenuItem(
text = {
Column {
Text(item.title, fontWeight = FontWeight.SemiBold)
item.suggestedSeverity?.let {
Text("Gravedad sugerida: $it/10", style = MaterialTheme.typography.bodySmall)
}
}
},
onClick = {
selectedCatalogId = item.id
other = false
severityText = item.suggestedSeverity?.toString().orEmpty()
catalogExpanded = false
search = ""
keyboard?.hide()
focusManager.moveFocus(FocusDirection.Down)
},
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(if (chosen) "${item.title}" else item.title, fontWeight = FontWeight.SemiBold)
Text(
listOfNotNull(item.categoryName, item.code, item.suggestedSeverity?.let { "Gravedad sugerida $it" })
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
)
}
DropdownMenuItem(
text = { Text("Otro / No está en la lista", fontWeight = FontWeight.SemiBold) },
onClick = {
other = true
selectedCatalogId = null
severityText = ""
catalogExpanded = false
search = ""
keyboard?.hide()
focusManager.moveFocus(FocusDirection.Down)
},
)
}
}
OutlinedButton(
onClick = {
other = true
selectedCatalogId = null
severityText = ""
},
modifier = Modifier.fillMaxWidth(),
) {
Text(if (other) "✓ OTROS · Hallazgo no catalogado" else "OTROS · No está en el catálogo")
}
if (other) {
Text("Cargá un nombre claro para identificar el Hallazgo.", style = MaterialTheme.typography.bodySmall)
options.catalog.other.help?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
OutlinedTextField(
value = customTitle,
onValueChange = { customTitle = it },
label = { Text("Nombre del Hallazgo *") },
label = { Text("Título del nuevo Hallazgo *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
OutlinedTextField(
value = customLegalBasis,
onValueChange = { customLegalBasis = it },
label = { Text("Normativa (opcional)") },
label = { Text("Base legal / normativa (opcional)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
}
Text("2. Qué observaste", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("2. Describí lo observado", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
selected?.let {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -445,21 +314,23 @@ fun FieldFindingScreen(model: MainViewModel) {
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Qué observaste *") },
placeholder = { Text("Describí brevemente el problema") },
label = { Text("Descripción del Hallazgo *") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 6,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
)
OutlinedTextField(
value = severityText,
onValueChange = { value -> severityText = value.filter(Char::isDigit).take(2) },
label = { Text("Gravedad (1 a 10, opcional)") },
label = { Text("Gravedad 1 a 10") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
OutlinedTextField(
value = correctionDueOn,
onValueChange = { correctionDueOn = it },
label = { Text("Fecha de corrección AAAA-MM-DD (opcional)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { keyboard?.hide(); focusManager.clearFocus() }),
singleLine = true,
)
@@ -473,6 +344,7 @@ fun FieldFindingScreen(model: MainViewModel) {
customLegalBasis = if (other) customLegalBasis else null,
description = description,
severity = severity,
correctionDueOn = correctionDueOn,
)
},
enabled = choiceReady && description.isNotBlank() && (severity == null || severity in 1..10) && !model.busy,
@@ -489,19 +361,13 @@ fun FieldFindingScreen(model: MainViewModel) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Hallazgo registrado", fontWeight = FontWeight.Bold)
Text("${finding.code} · ${finding.title}")
val createdEvidence = model.fieldFindingEvidence[finding.id].orEmpty()
val createdPhotoCount = createdEvidence.count { it.kind == "PHOTO" }
Button(
onClick = { requestPhoto(finding.id) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (createdPhotoCount == 0) "Agregar foto con GPS" else "Agregar otra foto con GPS")
Text("Tomar foto con GPS")
}
Text(
if (createdPhotoCount == 0) "Podés adjuntar todas las fotos que necesites." else "Fotos adjuntas: $createdPhotoCount. Podés seguir agregando.",
style = MaterialTheme.typography.bodySmall,
)
Text("También podés registrar otro Hallazgo sobre el mismo Inventario.", style = MaterialTheme.typography.bodySmall)
}
}
@@ -523,17 +389,13 @@ private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = su
}
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(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("No se pudo obtener una ubicación GPS actual."))
else continuation.resume(FindingGeoSnapshot(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -3,18 +3,12 @@ package com.korexlabs.dhinspeccion.ui
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -35,9 +29,7 @@ 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@@ -45,7 +37,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.korexlabs.dhinspeccion.MainViewModel
import com.korexlabs.dhinspeccion.R
private const val BIOMETRIC_PREFS = "dh_v2_biometric"
private const val BIOMETRIC_ENABLED = "enabled"
@@ -83,8 +74,8 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
if (model.session == null) unlocked = false
}
DhTheme {
Surface(Modifier.fillMaxSize().safeDrawingPadding().imePadding()) {
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
when {
model.session == null -> EnhancedLoginScreen(model) {
passwordLoginInFlight = true
@@ -100,8 +91,8 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
},
)
model.fieldFindingOptions != null -> FieldFindingScreen(model)
model.visit != null -> ModernVisitRoot(model)
else -> MobileHomeScreen(model)
model.visit != null -> F3VisitRoot(model)
else -> DhApp(model)
}
}
}
@@ -110,17 +101,11 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
@Composable
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
var identifier by rememberSaveable { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(170.dp),
contentScale = ContentScale.Fit,
)
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Aplicación de campo · Dirección de Hidrocarburos")
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -128,7 +113,7 @@ private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Uni
OutlinedTextField(
value = identifier,
onValueChange = { identifier = it },
label = { Text("Usuario o correo electrónico") },
label = { Text("Usuario o email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
@@ -207,18 +192,12 @@ private fun BiometricUnlockScreen(
LaunchedEffect(Unit) { authenticate() }
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.ic_mendoza_launcher_exact),
contentDescription = "Gobierno de Mendoza",
modifier = Modifier.fillMaxWidth().height(140.dp),
contentScale = ContentScale.Fit,
)
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Text("Ingresá con tu huella")
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
@@ -57,34 +57,30 @@ fun MobileActsScreen(
val visit = model.visit ?: return
val selected = model.selectedAct
val closure = model.actClosure
val representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
val context = LocalContext.current
val scope = rememberCoroutineScope()
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var actNarrative by rememberSaveable(selected?.id) {
mutableStateOf(selected?.summary.takeUnless { it == null || it.startsWith("Se inició la inspección ") || it.startsWith("Acta de inspección en curso.") }.orEmpty())
}
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var attendance by rememberSaveable(selected?.id) {
mutableStateOf(representativeSeed?.attendanceStatus ?: "PRESENT")
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
}
var fullName by rememberSaveable(selected?.id) {
mutableStateOf(representativeSeed?.fullName.orEmpty())
mutableStateOf(closure?.responsible?.fullName.orEmpty())
}
var documentType by rememberSaveable(selected?.id) {
mutableStateOf("DNI")
mutableStateOf(closure?.responsible?.documentType ?: "DNI")
}
var documentNumber by rememberSaveable(selected?.id) {
mutableStateOf(representativeSeed?.documentNumber.orEmpty())
mutableStateOf(closure?.responsible?.documentNumber.orEmpty())
}
var position by rememberSaveable(selected?.id) {
mutableStateOf(representativeSeed?.position.orEmpty())
mutableStateOf(closure?.responsible?.position.orEmpty())
}
var email by rememberSaveable(selected?.id) {
mutableStateOf(representativeSeed?.email.orEmpty())
mutableStateOf(closure?.responsible?.email.orEmpty())
}
var phone by rememberSaveable(selected?.id) {
mutableStateOf(representativeSeed?.phone.orEmpty())
mutableStateOf(closure?.responsible?.phone.orEmpty())
}
var absenceReason by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
@@ -95,17 +91,26 @@ fun MobileActsScreen(
LaunchedEffect(visit.id) { model.reloadActs() }
fun signCompanyWithGeo(file: File) {
fun signWithGeo(file: File, company: Boolean) {
scope.launch {
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
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,
)
}
}
}
@@ -121,22 +126,17 @@ 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. Creá la primera y después agregá los Hallazgos.")
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
}
model.acts.forEach { act ->
val active = selected?.id == act.id
val urgency = when (act.urgency) {
"URGENT" -> "URGENTE"
"NON_URGENT" -> "No urgente"
else -> "Urgencia pendiente"
}
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
OutlinedButton(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
@@ -156,68 +156,93 @@ fun MobileActsScreen(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Nueva Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", 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)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { newActUrgency = "URGENT" },
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Text(
"La urgencia se define al cerrar el Acta, después de completar los Hallazgos.",
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
} else {
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
},
style = MaterialTheme.typography.bodySmall,
)
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() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
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") }
}
}
}
}
if (selected != null) {
HorizontalDivider()
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status))
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
}
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
}
}
when (selected.status) {
"DRAFT" -> {
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("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth())
if (closure?.responsible == null && closure?.representativeSuggestion != null) {
Text("Datos precargados del Acta anterior. Confirmalos para esta Acta.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "$kind" else kind) })
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth())
Button(
onClick = {
model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone)
},
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar representante") }
) { Text("Guardar responsable") }
} else {
OutlinedTextField(
absenceReason,
@@ -234,80 +259,80 @@ fun MobileActsScreen(
}
HorizontalDivider()
Text("Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet firma el acompañante de la empresa.")
OutlinedTextField(
actNarrative,
{ actNarrative = it },
label = { Text("Descripción de lo actuado *") },
minLines = 4,
modifier = Modifier.fillMaxWidth(),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { closingUrgency = "NON_URGENT" },
label = { Text(if (closingUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { closingUrgency = "URGENT" },
label = { Text(if (closingUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
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("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button(
onClick = { model.prepareSelectedAct(closingUrgency, actNarrative) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar contenido y pasar a firma") }
) { 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" || companyOutcome?.status == "ABSENT"
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("El contenido ya es inmutable. La firma del inspector está guardada en el Dashboard y se aplicará al cierre final.")
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider()
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
Text("Firma del inspector", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary)
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
HorizontalDivider()
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
"REFUSED" -> "Se negó a firmar"
"ABSENT" -> "Ausencia documentada"
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar"
else -> companyOutcome.status
}
Text("$detail", color = MaterialTheme.colorScheme.primary)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
val recordedAbsence = closure.responsible.absenceReason.orEmpty()
Text("El representante fue registrado como ausente. Confirmá esta ausencia para dejar la constancia documental.")
Text(recordedAbsence, style = MaterialTheme.typography.bodySmall)
OutlinedButton(
onClick = { model.recordCompanyOutcome("ABSENT", recordedAbsence) },
enabled = !model.busy && recordedAbsence.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Confirmar ausencia documentada") }
Text(
"El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
style = MaterialTheme.typography.bodyMedium,
)
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disconformidad" else "En disconformidad") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Motivo de disconformidad *") },
label = { Text("Manifestación de disidencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del representante de la empresa (acompañante)",
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signCompanyWithGeo(file) },
label = "Firma del responsable de empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
)
Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
refusalReason,
{ refusalReason = it },
@@ -317,20 +342,23 @@ fun MobileActsScreen(
)
OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && refusalReason.trim().length >= 10,
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
HorizontalDivider()
if (companyResolved) {
if (inspectorSigned && companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Aplicar mi firma y cerrar Acta") }
) { Text("SELLAR Acta definitivamente") }
} else {
Text("Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.", style = MaterialTheme.typography.bodySmall)
Text(
"Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
style = MaterialTheme.typography.bodySmall,
)
}
}
@@ -403,17 +431,13 @@ private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo =
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(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
} catch (error: SecurityException) {
if (continuation.isActive) continuation.resumeWithException(error)
}
LocationServices.getFusedLocationProviderClient(context)
.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -1,242 +0,0 @@
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.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)) {
Column(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column {
Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(session.displayName, style = MaterialTheme.typography.bodySmall)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = { model.loadVisits() },
enabled = !model.busy && !opening,
modifier = Modifier.weight(1f),
) {
Text("Actualizar", maxLines = 1)
}
OutlinedButton(
onClick = { model.logout() },
enabled = !opening,
modifier = Modifier.weight(1f),
) {
Text("Salir", maxLines = 1)
}
}
}
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(visitStatusLabelEs(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,
)
}
}
}
@@ -1,586 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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 representativeSeed = closure?.responsible ?: closure?.representativeSuggestion
val context = LocalContext.current
val scope = rememberCoroutineScope()
var closingUrgency by rememberSaveable(selected?.id) { mutableStateOf("") }
var actNarrative by rememberSaveable(selected?.id) {
mutableStateOf(selected?.summary.takeUnless { it == null || it.startsWith("Se inició la inspección ") || it.startsWith("Acta de inspección en curso.") }.orEmpty())
}
var fullName by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.fullName.orEmpty()) }
var documentType by rememberSaveable(selected?.id) { mutableStateOf("DNI") }
var documentNumber by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.documentNumber.orEmpty()) }
var position by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.position.orEmpty()) }
var email by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.email.orEmpty()) }
var phone by rememberSaveable(selected?.id) { mutableStateOf(representativeSeed?.phone.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 signCompanyWithGeo(file: File) {
scope.launch {
val geo = runCatching { currentModernActSignatureGeo(context) }.getOrNull()
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
}
}
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.pendingSyncCount > 0) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"${model.pendingSyncCount} cambio${if (model.pendingSyncCount == 1) "" else "s"} guardado${if (model.pendingSyncCount == 1) "" else "s"} en este dispositivo",
fontWeight = FontWeight.Bold,
)
Text(
"Podés seguir trabajando sin internet. Los cambios se sincronizan automáticamente cuando vuelve la conexión.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
OutlinedButton(onClick = { model.syncPendingNow() }, enabled = !model.busy) {
Text("Sincronizar ahora")
}
}
}
}
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.surfaceVariant,
tonalElevation = 0.dp,
) {
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)} · ${when (act.urgency) { "URGENT" -> "Urgente"; "NON_URGENT" -> "No urgente"; else -> "Urgencia pendiente" }}",
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(
"La urgencia se define recién al cerrar el Acta, cuando ya se conoce el resultado de la inspección.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.createAct() },
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) {
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("Representante de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Identificá a la persona que acompaña el recorrido. Al finalizar esta Acta podrá firmar conforme, firmar en disconformidad o negarse a firmar.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombres y apellidos *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(documentNumber, { documentNumber = it.filter(Char::isDigit) }, label = { Text("DNI *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(position, { position = it }, label = { Text("Cargo / función *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(email, { email = it }, label = { Text("Email *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
if (closure?.responsible == null && closure?.representativeSuggestion != null) {
Text("Datos precargados del Acta anterior. Confirmalos antes de cerrar esta Acta.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Button(
onClick = { model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) },
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank() && email.contains("@") && email.contains("."),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar representante") }
}
}
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("Cerrar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"Al cerrar el contenido, el Acta queda inmutable y pasa a Para firmar. La firma del inspector se toma de Mi perfil del Dashboard; en la tablet sólo firma el acompañante de la empresa. La urgencia pertenece al Acta completa. La urgencia no se asigna a cada Hallazgo.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
actNarrative,
{ actNarrative = it },
label = { Text("Descripción de lo actuado *") },
supportingText = { Text("Relatá la inspección y sus observaciones. Se incorpora al acta antes de sellarla.") },
minLines = 4,
modifier = Modifier.fillMaxWidth(),
)
Text("Urgencia del Acta", fontWeight = FontWeight.SemiBold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = closingUrgency == "NON_URGENT",
onClick = { closingUrgency = "NON_URGENT" },
label = { Text("No urgente") },
)
FilterChip(
selected = closingUrgency == "URGENT",
onClick = { closingUrgency = "URGENT" },
label = { Text("Urgente") },
)
}
Text(
when (closingUrgency) {
"URGENT" -> "El plazo urgente se computará desde el Acta según la política vigente."
"NON_URGENT" -> "El plazo no urgente comenzará con la oficialización GEDO."
else -> "Elegí una opción para habilitar el cierre."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { model.prepareSelectedAct(closingUrgency, actNarrative) },
enabled = !model.busy && closure?.responsible != null && closingUrgency.isNotBlank() && actNarrative.trim().length in 10..20000,
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar contenido y pasar a firma") }
}
}
}
"LOCKED" -> {
val signatures = closure?.signatures.orEmpty()
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolvedOnServer = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED" || companyOutcome?.status == "ABSENT"
val companyResolved = companyResolvedOnServer || model.selectedActCompanySignaturePending
ElevatedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.EditNote, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text("Para firmar", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text(
"El contenido ya está cerrado e inmutable. Ahora corresponde la manifestación y firma del acompañante de la empresa.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when (model.inspectorSignatureConfigured) {
true -> Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
) {
Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Text(
"Firma del inspector configurada en Mi perfil. Se aplicará automáticamente al cierre definitivo de esta Acta.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
}
false -> Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer,
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.ErrorOutline, null, tint = MaterialTheme.colorScheme.error)
Text(
"Falta configurar la firma del inspector en Mi perfil del Dashboard. La firma de la empresa puede guardarse igual; el cierre quedará pendiente hasta completar este requisito.",
Modifier.weight(1f),
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(onClick = { model.refreshInspectorSignatureStatus() }, enabled = !model.busy) {
Text("Volver a verificar")
}
}
}
null -> Text(
"La firma del inspector se validará con el Dashboard al sincronizar.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
Text("Acompañante / representante de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolvedOnServer) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firmó en disconformidad" else "Firmó en conformidad"
"REFUSED" -> "Se negó a firmar"
"ABSENT" -> "Ausencia documentada"
else -> humanBackendLabelEs(companyOutcome.status)
}
SuccessLine(detail)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (model.selectedActCompanySignaturePending) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
SuccessLine("Firma guardada en el dispositivo")
Text(
"Todavía no llegó al servidor. Podés continuar; se enviará automáticamente cuando haya conexión.",
style = MaterialTheme.typography.bodySmall,
)
}
}
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.errorContainer) {
Text(
"Esta Acta conserva un registro histórico de ausencia. El flujo actual requiere identificar al acompañante antes de pasar el contenido a firma.",
Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
} else {
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 disconformidad") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Motivo de disconformidad *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del representante de la empresa (acompañante)",
enabled = !model.busy && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signCompanyWithGeo(file) },
)
Text("Si se niega a firmar, asentá el motivo para dejar la constancia.", 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 && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
if (companyResolved) {
if (model.selectedActSealPending) {
Surface(Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.secondaryContainer) {
Text(
"Cierre guardado en este dispositivo · pendiente de sincronización",
Modifier.padding(12.dp),
fontWeight = FontWeight.SemiBold,
)
}
} else {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text(if (model.inspectorSignatureConfigured == false) "Guardar cierre pendiente" else "Firmar y cerrar Acta")
}
}
} else {
Text(
"Completá la firma, disconformidad o negativa del acompañante para habilitar el cierre final.",
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 firmada y cerrada", fontWeight = FontWeight.Bold)
Text("Desde este cierre se genera el PDF del Acta y el informe INF editable (DOCX) 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 firmadas y cerradas")
} else {
Text(
"Falta firmar y cerrar: ${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 = actStatusLabelEs(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
@@ -49,7 +49,7 @@ fun SignaturePad(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text(
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora a la huella de integridad del Acta.",
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
style = MaterialTheme.typography.bodySmall,
)
Canvas(
@@ -1,99 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.korexlabs.dhinspeccion.data.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
internal fun TechnicalFieldsButton(assetId: String, enabled: Boolean) {
val context = LocalContext.current
val repository = remember { TechnicalRepository(context) }
val scope = rememberCoroutineScope()
var open by rememberSaveable(assetId) { mutableStateOf(false) }
var busy by remember(assetId) { mutableStateOf(false) }
var snapshot by remember(assetId) { mutableStateOf<TechnicalValues?>(null) }
var error by remember(assetId) { mutableStateOf<String?>(null) }
var saved by remember(assetId) { mutableStateOf(false) }
val values = remember(assetId) { mutableStateMapOf<String, String>() }
OutlinedButton(onClick = { open = true }, enabled = enabled,
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp)) {
Text(if (saved) "Datos técnicos guardados · Revisar" else "Completar datos técnicos")
}
if (!open) return
LaunchedEffect(assetId, open) {
busy = true; error = null; snapshot = null
try {
val loaded = repository.get(assetId)
snapshot = loaded
values.clear()
loaded.values.forEach { (id, value) -> values[id] = value?.toString().orEmpty() }
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
AlertDialog(
onDismissRequest = { if (!busy) open = false },
title = { Text(snapshot?.family?.name ?: "Datos técnicos") },
text = {
Column(Modifier.fillMaxWidth().heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Son los mismos campos de la clasificación administrada en oficina. Los marcados con * son obligatorios.")
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
snapshot?.definitions?.filter { it.isActive }?.forEach { definition ->
val label = definition.name + (if (definition.isRequired) " *" else "") + (definition.unit?.let { " ($it)" } ?: "")
val current = values[definition.id].orEmpty()
if (definition.dataType in setOf("BOOLEAN", "SELECT")) {
Text(label)
val options = if (definition.dataType == "BOOLEAN") listOf("true", "false") else definition.options.orEmpty()
options.forEach { option ->
FilterChip(selected = current == option, enabled = !busy,
onClick = { values[definition.id] = option },
label = { Text(if (definition.dataType == "BOOLEAN") if (option == "true") "" else "No" else option) })
}
if (!definition.isRequired) TextButton(enabled = !busy, onClick = { values[definition.id] = "" }) { Text("Sin dato") }
} else {
OutlinedTextField(value = current, onValueChange = { values[definition.id] = it },
label = { Text(label) }, enabled = !busy, modifier = Modifier.fillMaxWidth(),
supportingText = {
when (definition.dataType) {
"DATE" -> Text("AAAA-MM-DD")
"DATETIME" -> Text("AAAA-MM-DDTHH:MM:SS-03:00")
else -> Unit
}
})
}
}
if (snapshot?.definitions?.none { it.isActive } == true) Text("Esta clasificación no tiene campos técnicos activos.")
}
},
confirmButton = {
TextButton(enabled = !busy && snapshot != null, onClick = {
val current = snapshot ?: return@TextButton
busy = true; error = null
scope.launch {
try {
val payload = current.definitions.filter { it.isActive }.mapNotNull { definition ->
technicalInputValue(definition, values[definition.id].orEmpty())?.let { definition.id to it }
}.toMap()
snapshot = repository.save(assetId, payload)
saved = true; open = false
} catch (cancelled: CancellationException) { throw cancelled }
catch (failure: Exception) { error = DhRepository.humanError(failure) }
finally { busy = false }
}
}) { Text("Guardar datos") }
},
dismissButton = { TextButton(enabled = !busy, onClick = { open = false }) { Text("Cerrar") } },
)
}
@@ -1,50 +0,0 @@
package com.korexlabs.dhinspeccion.ui
import java.util.Locale
internal fun visitStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"PLANNED" -> "Planificada"
"IN_PROGRESS" -> "En curso"
"CLOSED" -> "Cerrada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun actStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"DRAFT" -> "En elaboración"
"LOCKED" -> "Para firmar"
"SEALED" -> "Firmada y cerrada"
"CLOSED" -> "Cerrada"
"RECTIFIED" -> "Rectificada"
"CANCELLED" -> "Cancelada"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun findingStatusLabelEs(status: String?): String = when (status?.uppercase(Locale.ROOT)) {
"OPEN" -> "Abierto"
"PENDING" -> "Pendiente"
"COMPANY_RESPONSE_PENDING" -> "Pendiente de respuesta de la empresa"
"COMPANY_RESPONDED" -> "Respondido por la empresa"
"VERIFICATION_PENDING" -> "Pendiente de verificación"
"VERIFICATION_SCHEDULED" -> "Verificación programada"
"READY_TO_CLOSE" -> "Listo para cierre"
"CLOSED" -> "Cerrado"
"CANCELLED" -> "Cancelado"
null, "" -> "Sin estado"
else -> humanBackendLabelEs(status)
}
internal fun documentTypeLabelEs(type: String): String = when (type.uppercase(Locale.ROOT)) {
"DNI" -> "DNI"
"CUIL" -> "CUIL"
"PASSPORT" -> "Pasaporte"
"OTHER" -> "Otro"
else -> humanBackendLabelEs(type)
}
internal fun humanBackendLabelEs(value: String): String {
val clean = value.trim().replace('_', ' ').replace('-', ' ').lowercase(Locale("es", "AR"))
return clean.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale("es", "AR")) else it.toString() }
}
@@ -1,57 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 8.2 KiB

@@ -1,26 +0,0 @@
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("Tipo de instalación *"))
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"))
}
}
@@ -1,29 +0,0 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldAttributeDefinition
import com.korexlabs.dhinspeccion.data.fieldAttributePayload
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FieldAttributePayloadTest {
@Test
fun payloadUsesDefinitionIdsExpectedByApiAndKeepsTypedValues() {
val definitions = listOf(
FieldAttributeDefinition("id-marca", "campo_marca", "Marca", "TEXT"),
FieldAttributeDefinition("id-capacidad", "campo_capacidad", "Capacidad", "NUMBER"),
FieldAttributeDefinition("id-serie", "campo_numero_serie", "Número de serie", "TEXT"),
)
val payload = fieldAttributePayload(
definitions,
mapOf("campo_marca" to " algo ", "campo_capacidad" to "37,73", "campo_numero_serie" to " "),
)
assertEquals("algo", payload["id-marca"])
assertEquals(37.73, payload["id-capacidad"])
assertFalse(payload.containsKey("id-serie"))
assertTrue(payload.keys.none { it.startsWith("campo_") })
}
}
@@ -1,24 +0,0 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.FieldCoordinates
import org.junit.Assert.*
import org.junit.Test
class FieldCoordinatesTest {
@Test fun realGpsIsRoundedToApiPrecision() {
assertEquals(-32.889459, FieldCoordinates.latitude(-32.889458762), 0.0)
assertEquals(-68.845839, FieldCoordinates.longitude(-68.845838912), 0.0)
assertEquals(4.123, FieldCoordinates.accuracy(4.123456789), 0.0)
}
@Test fun boundsAndZeroRemainValid() {
assertEquals(90.0, FieldCoordinates.latitude(90.0), 0.0)
assertEquals(-180.0, FieldCoordinates.longitude(-180.0), 0.0)
assertEquals(0.0, FieldCoordinates.accuracy(0.0), 0.0)
}
@Test fun invalidCoordinatesAreRejected() {
for (value in listOf(Double.NaN, Double.POSITIVE_INFINITY, 90.1, -90.1)) {
try { FieldCoordinates.latitude(value); fail("Invalid latitude accepted") }
catch (_: IllegalArgumentException) { }
}
try { FieldCoordinates.accuracy(-1.0); fail("Negative accuracy accepted") }
catch (_: IllegalArgumentException) { }
}
}
@@ -1,76 +0,0 @@
package com.korexlabs.dhinspeccion
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FinalFieldFlowContractTest {
private val visit = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt").readText()
private val acts = File("src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt").readText()
private val findings = File("src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt").readText()
private val home = File("src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt").readText()
private val labels = File("src/main/java/com/korexlabs/dhinspeccion/ui/UiSpanishLabels.kt").readText()
@Test
fun inspectionActFindingFlowMatchesFieldProcedure() {
assertTrue(visit.contains("Iniciar inspección"))
assertTrue(acts.contains("Crear nueva Acta"))
assertTrue(acts.contains("Agregar Hallazgo"))
assertTrue(visit.contains("Instalación"))
assertTrue(visit.contains("Subinstalación"))
assertTrue(visit.contains("Guardar y tomar foto"))
assertTrue(acts.contains("Cerrar contenido y pasar a firma"))
assertTrue(acts.contains("Nombres y apellidos *"))
assertTrue(acts.contains("Cargo / función *"))
assertTrue(acts.contains("Email *"))
assertTrue(acts.contains("En disconformidad"))
assertTrue(acts.contains("Motivo de disconformidad *"))
assertTrue(acts.contains("urgencia pertenece al Acta completa"))
assertTrue(acts.contains("urgencia no se asigna a cada Hallazgo"))
assertTrue(labels.contains("\"LOCKED\" -> \"Para firmar\""))
}
@Test
fun fieldCreateKeepsDashboardEquivalentMinimumAndCaptureBarrier() {
assertTrue(visit.contains("Nombre técnico *"))
assertTrue(visit.contains("Nombre habitual (opcional)"))
assertTrue(visit.contains("Descripción (opcional)"))
assertTrue(visit.contains("familyReady"))
assertTrue(visit.contains("attributesReady"))
assertTrue(visit.contains("GPS + foto"))
assertTrue(visit.contains("La foto es obligatoria antes de registrar Hallazgos"))
}
@Test
fun structuralSelectionUsesTheServerTypeCode() {
assertTrue(visit.contains("type.code.ifBlank { type.typeName ?: type.name }"))
assertTrue(visit.contains("modernItemTypeCode(it) in setOf(\"yacimiento\", \"instalacion\", \"subinstalacion\")"))
}
@Test
fun currentFieldUiDoesNotExposeBackendEnglishStates() {
assertTrue(home.contains("visitStatusLabelEs(visit.status)"))
assertTrue(findings.contains("findingStatusLabelEs(finding.status)"))
assertTrue(acts.contains("DNI *"))
assertFalse(acts.contains("PASSPORT"))
assertFalse(acts.contains("OTHER"))
assertTrue(labels.contains("\"PASSPORT\" -> \"Pasaporte\""))
assertTrue(labels.contains("\"OTHER\" -> \"Otro\""))
assertFalse(acts.contains("Finalizar y BLOQUEAR Acta"))
assertFalse(acts.contains("Acta BLOQUEADA"))
assertFalse(acts.contains("SELLAR Acta"))
assertFalse(acts.contains("Acta SELLADA"))
assertFalse(acts.contains("Text(\"Email\")"))
}
@Test
fun findingCreationIsCompactSearchableAndHasNoCorrectionDate() {
assertTrue(findings.contains("ExposedDropdownMenuBox"))
assertTrue(findings.contains("Tipo de Hallazgo *"))
assertTrue(findings.contains("Buscar o seleccionar"))
assertTrue(findings.contains("Otro / No está en la lista"))
assertFalse(findings.contains("Fecha de corrección"))
assertFalse(findings.contains("Hallazgos del modelo autoritativo"))
}
}
@@ -1,105 +0,0 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import java.io.IOException
import kotlinx.coroutines.*
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
class MobileSessionCoordinatorTest {
private fun old() = StoredSession("u1", "inspector", "Inspector", "a1", "r1")
private fun response() = MobileSessionResponse(MobileUser("u1", "inspector"), "a2", "r2", 900)
private fun http(code: Int) = HttpException(Response.error<Unit>(code, "error".toResponseBody()))
private class Store(var value: StoredSession?) {
var clears = 0
fun load() = value
fun clear() { clears++; value = null }
fun save(r: MobileSessionResponse): StoredSession =
StoredSession(r.user.id, r.user.username, r.user.username, r.accessToken, r.refreshToken).also { value = it }
}
@Test fun concurrentClientsRotateTheTokenOnlyOnce() = runBlocking {
val previous = old(); val store = Store(previous); var calls = 0
val results = coroutineScope {
(1..4).map {
async {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
calls++; delay(20); response()
}
}
}.awaitAll()
}
assertEquals(1, calls)
assertTrue(results.all { it.refreshToken == "r2" })
assertEquals(0, store.clears)
}
@Test fun networkAndServerFailuresKeepSessionForRetry() = runBlocking {
for (failure in listOf(IOException("offline"), http(500), http(429))) {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw failure }
fail("Expected request failure")
} catch (caught: Exception) { assertSame(failure, caught) }
assertEquals(previous, store.value)
assertEquals(0, store.clears)
}
}
@Test fun invalidRefreshClearsOnlyTheExpiredSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw http(401) }
fail("Expected unauthorized")
} catch (caught: HttpException) { assertEquals(401, caught.code()) }
assertNull(store.value)
assertEquals(1, store.clears)
}
@Test fun cancelledRefreshKeepsSession() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) { throw CancellationException("cancel") }
fail("Expected cancellation")
} catch (_: CancellationException) { }
assertEquals(previous, store.value)
}
@Test fun logoutDuringRefreshCannotBeUndone() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.clear(); response()
}
fail("Must reject a refresh after logout")
} catch (_: IllegalStateException) { }
assertNull(store.value)
}
@Test fun staleRequestCannotUseAnotherInspectorsSession() = runBlocking {
val previous = old(); val store = Store(previous.copy(userId = "u2", refreshToken = "other"))
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
fail("No refresh allowed for another inspector"); response()
}
fail("Must reject account switch")
} catch (_: IllegalStateException) { }
assertEquals("u2", store.value?.userId)
}
@Test fun lateUnauthorizedDoesNotClearANewLogin() = runBlocking {
val previous = old(); val store = Store(previous)
try {
MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear) {
store.value = previous.copy(refreshToken = "new-login")
throw http(401)
}
} catch (_: HttpException) { }
assertEquals("new-login", store.value?.refreshToken)
assertEquals(0, store.clears)
}
}
@@ -1,28 +0,0 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.*
import org.junit.Test
class OperationTrackerTest {
@Test fun doubleTapCannotQueueASecondWrite() {
val tracker = OperationTracker()
assertTrue(tracker.begin(mutation = true))
assertFalse(tracker.begin(mutation = true))
tracker.end()
assertFalse(tracker.busy)
assertTrue(tracker.begin(mutation = true))
}
@Test fun BusyRemainsUntilEveryReadCompletes() {
val tracker = OperationTracker()
assertTrue(tracker.begin(false)); assertTrue(tracker.begin(false))
tracker.end(); assertTrue(tracker.busy)
assertFalse(tracker.begin(true))
tracker.end(); assertFalse(tracker.busy)
}
@Test fun readDuringWriteDoesNotReleaseItsGuardEarly() {
val tracker = OperationTracker()
assertTrue(tracker.begin(true)); assertTrue(tracker.begin(false))
tracker.end(); assertFalse(tracker.begin(true))
tracker.end(); assertTrue(tracker.begin(true))
}
}
@@ -1,20 +0,0 @@
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(39, BuildConfig.VERSION_CODE)
assertEquals("0.19.11-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://"))
}
}
@@ -1,28 +0,0 @@
package com.korexlabs.dhinspeccion
import com.korexlabs.dhinspeccion.data.*
import org.junit.Assert.*
import org.junit.Test
class TechnicalValuesTest {
private fun field(type: String, required: Boolean = false) = TechnicalDefinition("id", "code", "Campo", type, required, options = listOf("A", "B"))
@Test fun decimalCommaAndBooleanFalseKeepTheirTypes() {
assertEquals(12.5, technicalInputValue(field("NUMBER"), "12,5"))
assertEquals(false, technicalInputValue(field("BOOLEAN", true), "false"))
assertEquals(0.0, technicalInputValue(field("NUMBER", true), "0"))
}
@Test fun optionalBlankIsOmittedButRequiredBlankFails() {
assertNull(technicalInputValue(field("TEXT"), " "))
try { technicalInputValue(field("TEXT", true), ""); fail("Required missing") }
catch (_: IllegalArgumentException) { }
}
@Test fun invalidNumbersDatesAndOptionsFailBeforeSave() {
for ((type, value) in listOf("NUMBER" to "NaN", "DATE" to "2026-02-30", "SELECT" to "C", "BOOLEAN" to "quizás")) {
try { technicalInputValue(field(type), value); fail("Invalid value accepted") }
catch (_: IllegalArgumentException) { }
}
}
@Test fun validDatesAndSelectRemainCanonical() {
assertEquals("2026-09-14", technicalInputValue(field("DATE"), "2026-09-14"))
assertEquals("A", technicalInputValue(field("SELECT"), "A"))
assertEquals("2026-09-14T13:00:00Z", technicalInputValue(field("DATETIME"), "2026-09-14T10:00:00-03:00"))
}
}
-1
View File
@@ -2,5 +2,4 @@ plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false
id("org.jetbrains.kotlin.kapt") version "2.2.20" apply false
}
-2
View File
@@ -4,7 +4,6 @@ COPY package*.json ./
RUN npm ci
COPY nest-cli.json tsconfig.json ./
COPY src ./src
COPY assets ./assets
RUN npm run build
FROM node:24-alpine AS runner
@@ -14,7 +13,6 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/assets ./assets
RUN mkdir -p /app/storage/asset-media/imports && chown -R node:node /app/storage
USER node
EXPOSE 3000
Binary file not shown.
Binary file not shown.
-78
View File
@@ -1,78 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: DejaVu fonts
Upstream-Author: Stepan Roh <src@users.sourceforge.net> (original author),
see /usr/share/doc/fonts-dejavu-core/AUTHORS for full list
Source: https://dejavu-fonts.github.io/
Files: *
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
Bitstream Vera is a trademark of Bitstream, Inc.
DejaVu changes are in public domain.
License: bitstream-vera
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
.
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
.
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
Files: debian/*
Copyright: (C) 2005-2006 Peter Cernak <pce@users.sourceforge.net>
(C) 2006-2011 Davide Viti <zinosat@tiscali.it>
(C) 2011-2013 Christian Perrier <bubulle@debian.org>
(C) 2013 Fabian Greffrath <fabian+debian@greffrath.com>
License: GPL-2+
This program is free software; you can redistribute it
and/or modify it under the terms of the GNU General Public
License as published by the Free Software Foundation; either
version 2 of the License, or (at your option) any later
version.
.
This program is distributed in the hope that it will be
useful, but WITHOUT ANY WARRANTY; without even the implied
warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
PURPOSE. See the GNU General Public License for more
details.
.
You should have received a copy of the GNU General Public
License along with this package; if not, write to the Free
Software Foundation, Inc., 51 Franklin St, Fifth Floor,
Boston, MA 02110-1301 USA
.
On Debian systems, the full text of the GNU General Public
License version 2 can be found in the file
/usr/share/common-licenses/GPL-2'.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+9 -748
View File
@@ -1,12 +1,12 @@
{
"name": "dhv2-api",
"version": "0.29.0-11",
"version": "0.20.0-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dhv2-api",
"version": "0.29.0-11",
"version": "0.20.0-2",
"license": "UNLICENSED",
"dependencies": {
"@nestjs/common": "^11.0.0",
@@ -21,11 +21,9 @@
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"helmet": "^8.0.0",
"pdfkit": "^0.20.2",
"pg": "^8.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"sharp": "^0.35.4",
"typeorm": "^0.3.0"
},
"devDependencies": {
@@ -34,7 +32,6 @@
"@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.3",
"@types/node": "^24.0.0",
"@types/pdfkit": "^0.17.6",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.9.0"
@@ -219,16 +216,6 @@
"node": ">=12"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
@@ -677,506 +664,6 @@
"node": ">=18"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@inquirer/ansi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
@@ -1972,30 +1459,6 @@
"typeorm": "^0.3.0 || ^1.0.0-dev"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@@ -2021,15 +1484,6 @@
"integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
@@ -2205,16 +1659,6 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/pdfkit": {
"version": "0.17.6",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -2741,15 +2185,6 @@
"concat-map": "0.0.1"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -3370,21 +2805,6 @@
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
@@ -3736,6 +3156,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
@@ -3768,12 +3189,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-type": {
"version": "21.3.4",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
@@ -3813,32 +3228,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/fontkit/node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4517,25 +3906,6 @@
"integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==",
"license": "MIT"
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -4796,9 +4166,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -5018,12 +4388,6 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5111,20 +4475,6 @@
"node": ">=8"
}
},
"node_modules/pdfkit": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.2.tgz",
"integrity": "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA==",
"license": "MIT",
"dependencies": {
"@noble/ciphers": "^1.3.0",
"@noble/hashes": "^1.8.0",
"fflate": "^0.8.3",
"fontkit": "^2.0.4",
"linebreak": "^1.1.0",
"png-js": "^2.0.0"
}
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
@@ -5244,14 +4594,6 @@
"node": ">=4"
}
},
"node_modules/png-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz",
"integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5324,9 +4666,9 @@
}
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
@@ -5451,12 +4793,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT"
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -5661,55 +4997,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -6150,12 +5437,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/to-buffer": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz",
@@ -6606,26 +5887,6 @@
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT"
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+1 -8
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.29.0-11",
"version": "0.26.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -28,11 +28,9 @@
"class-validator": "^0.14.2",
"cookie-parser": "^1.4.7",
"helmet": "^8.0.0",
"pdfkit": "^0.20.2",
"pg": "^8.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"sharp": "^0.35.4",
"typeorm": "^0.3.0"
},
"devDependencies": {
@@ -41,13 +39,8 @@
"@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.3",
"@types/node": "^24.0.0",
"@types/pdfkit": "^0.17.6",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "^5.9.0"
},
"overrides": {
"multer": "2.3.0",
"qs": "6.16.0"
}
}
@@ -1,9 +1,14 @@
import { Controller, Get, Param, ParseUUIDPipe, Query, Res } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { ActAdministrationService } from './act-administration.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service';
import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto';
import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto';
import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto';
@Controller('act-administration')
export class ActAdministrationQueueController {
@@ -16,8 +21,11 @@ export class ActAdministrationQueueController {
export class ActAdministrationController {
constructor(private readonly administration: ActAdministrationService) {}
@Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); }
// Historic deadlines and responses remain readable here.
// New administrative entries are recorded on the related Informe.
@Patch('deadline') @RequirePermissions('inspection_findings.follow_up')
setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); }
@Post('responses') @RequirePermissions('inspection_findings.follow_up')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } }))
addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); }
}
@Controller('act-company-responses')
@@ -36,20 +36,6 @@ 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) {}
@@ -81,9 +67,7 @@ export class FieldBriefingService {
});
}
// 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 plannedOn = String(visit.plannedStartAt ?? new Date().toISOString()).slice(0, 10);
const rows = (await this.dataSource.query(`
WITH prior_acts AS (
@@ -2,14 +2,13 @@ import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { PhaseADataModule } from '../core-data/phase-a-data.module';
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
import { RolesController } from './roles/roles.controller';
import { RolesService } from './roles/roles.service';
import { UsersController } from './users/users.controller';
import { UsersService } from './users/users.service';
@Module({
imports: [PhaseADataModule, AuditModule, AuthModule, InspectionReportsModule],
imports: [PhaseADataModule, AuditModule, AuthModule],
controllers: [UsersController, RolesController],
providers: [UsersService, RolesService],
})
@@ -28,9 +28,10 @@ export class CreateUserDto {
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsOptional()
@IsEmail()
@MaxLength(320)
email!: string;
email?: string | null;
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.replace(/\D/g, '') : null,
@@ -1,30 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsEmail,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
const optionalText = ({ value }: { value: unknown }) =>
typeof value === 'string' && value.trim() ? value.trim() : null;
export class UpdateSelfProfileDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsEmail()
@MaxLength(320)
email!: string;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(40)
phone?: string | null;
@Transform(optionalText)
@IsOptional()
@IsString()
@MaxLength(160)
jobTitle?: string | null;
}
@@ -1,65 +0,0 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
import { SmtpSecurityMode } from '../../../database/entities';
export enum UserSmtpMode {
SYSTEM = 'SYSTEM',
CUSTOM = 'CUSTOM',
}
const trimmed = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
export class UpdateUserSmtpSettingsDto {
@IsEnum(UserSmtpMode)
mode!: UserSmtpMode;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@Transform(trimmed)
@IsString()
@MinLength(1)
@MaxLength(255)
host?: string;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@IsInt()
@Min(1)
@Max(65535)
port?: number;
@ValidateIf((dto: UpdateUserSmtpSettingsDto) => dto.mode === UserSmtpMode.CUSTOM)
@IsEnum(SmtpSecurityMode)
securityMode?: SmtpSecurityMode;
@IsOptional()
@Transform(trimmed)
@IsString()
@MaxLength(255)
username?: string | null;
@IsOptional()
@IsString()
@MaxLength(512)
password?: string | null;
@IsOptional()
@Transform(trimmed)
@IsString()
@MaxLength(200)
fromName?: string | null;
@IsOptional()
@IsBoolean()
enabled = true;
}
@@ -1,7 +1,6 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
@@ -10,43 +9,24 @@ import {
Put,
Query,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { AuditService } from '../../audit/audit.service';
import { RequirePermissions } from '../../authorization/decorators/require-permissions.decorator';
import { CurrentAuth } from '../../auth/decorators/current-auth.decorator';
import type {
AuthPrincipal,
RequestWithContext,
} from '../../common/http/request-context';
import { AuditAction } from '../../database/entities';
import { SmtpDeliveryService } from '../../inspection-reports/smtp-delivery.service';
import {
MAX_INSPECTION_SIGNATURE_BYTES,
type UploadedInspectionSignatureFile,
} from '../../inspection-closing/inspection-signature-file';
import { administrationAuditContext } from '../common/administration-audit';
import { ChangeUserStatusDto } from './dto/change-user-status.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
import { UpdateUserSmtpSettingsDto } from './dto/update-user-smtp-settings.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(
private readonly users: UsersService,
private readonly smtp: SmtpDeliveryService,
private readonly audit: AuditService,
) {}
constructor(private readonly users: UsersService) {}
@Get()
@RequirePermissions('users.read')
@@ -54,112 +34,6 @@ export class UsersController {
return this.users.list(query);
}
@Get('self/profile')
selfProfile(@CurrentAuth() principal: AuthPrincipal) {
return this.users.getSelfProfile(principal.userId);
}
@Patch('self/profile')
updateSelfProfile(
@Body() dto: UpdateSelfProfileDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.updateSelfProfile(dto, principal, request);
}
@Get('self/signature')
selfSignature(@CurrentAuth() principal: AuthPrincipal) {
return this.users.getSelfSignature(principal.userId);
}
@Get('self/signature/content')
async selfSignatureContent(
@CurrentAuth() principal: AuthPrincipal,
@Res() response: Response,
): Promise<void> {
const signature = await this.users.getSelfSignatureContent(principal.userId);
response.setHeader('Content-Type', 'image/png');
response.setHeader('Content-Length', String(signature.buffer.length));
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('ETag', `"${signature.sha256}"`);
response.setHeader('X-Content-Type-Options', 'nosniff');
response.send(signature.buffer);
}
@Put('self/signature')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
}))
updateSelfSignature(
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.updateSelfSignature(file, principal, request);
}
@Delete('self/signature')
deleteSelfSignature(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.users.deleteSelfSignature(principal, request);
}
@Get('self/smtp')
selfSmtp(@CurrentAuth() principal: AuthPrincipal) {
return this.smtp.publicUserSettings(principal.userId);
}
@Put('self/smtp')
async updateSelfSmtp(
@Body() dto: UpdateUserSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const before = await this.smtp.publicUserSettings(principal.userId);
const after = await this.smtp.saveUserSettings(principal.userId, dto);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_SMTP_SETTINGS_UPDATED,
entityType: 'user_smtp_settings',
entityId: principal.userId,
beforeData: before as Record<string, unknown>,
afterData: after as Record<string, unknown>,
metadata: { passwordNeverReturned: true, scope: 'SELF' },
});
return after;
}
@Post('self/smtp/test')
async testSelfSmtp(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
const profile = await this.users.getSelfProfile(principal.userId);
if (!profile.email) throw new Error('El usuario no tiene email configurado');
const sent = await this.smtp.send({
to: profile.email,
subject: 'DH Inspección · Prueba de correo personal',
text: 'Este correo confirma que tu configuración de correo en DH Inspección funciona correctamente.',
attachment: {
filename: 'dh-inspeccion-prueba-correo.txt',
mimeType: 'text/plain',
content: Buffer.from('DH Inspección · Correo personal OK\n', 'utf8'),
},
}, principal.userId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_SMTP_TEST_SENT,
entityType: 'user_smtp_settings',
entityId: principal.userId,
afterData: { recipient: profile.email, messageId: sent.messageId },
metadata: { scope: 'SELF' },
});
return { ok: true, recipient: profile.email, messageId: sent.messageId };
}
@Post()
@RequirePermissions('users.create')
create(
+16 -131
View File
@@ -1,8 +1,6 @@
import { createHash } from 'node:crypto';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -31,12 +29,7 @@ import type { CreateUserDto } from './dto/create-user.dto';
import type { ListUsersQueryDto } from './dto/list-users-query.dto';
import type { ReplaceUserRolesDto } from './dto/replace-user-roles.dto';
import type { ResetUserPasswordDto } from './dto/reset-user-password.dto';
import type { UpdateSelfProfileDto } from './dto/update-self-profile.dto';
import type { UpdateUserDto } from './dto/update-user.dto';
import {
inspectInspectionSignatureFile,
type UploadedInspectionSignatureFile,
} from '../../inspection-closing/inspection-signature-file';
export interface UserRoleView {
id: string;
@@ -44,14 +37,6 @@ export interface UserRoleView {
name: string;
}
export interface UserReusableSignatureView {
configured: boolean;
mimeType: 'image/png' | null;
sizeBytes: number | null;
imageSha256: string | null;
updatedAt: Date | null;
}
export interface AdministrativeUserView {
id: string;
username: string;
@@ -71,7 +56,6 @@ export interface AdministrativeUserView {
createdAt: Date;
updatedAt: Date;
roles: UserRoleView[];
smtpMode: 'SYSTEM' | 'CUSTOM';
}
interface UserViewRow extends AdministrativeUserView {
@@ -92,10 +76,10 @@ function roleSelectionInvalid(): BadRequestException {
});
}
function userEmailRequired(): BadRequestException {
function inspectorEmailRequired(): BadRequestException {
return new BadRequestException({
code: 'USER_EMAIL_REQUIRED',
message: 'Cada usuario de Hidrocarburos debe tener un email válido',
code: 'INSPECTOR_EMAIL_REQUIRED',
message: 'Los usuarios con rol Inspector deben tener un email válido para recibir la documentación de sus inspecciones',
});
}
@@ -160,7 +144,6 @@ export class UsersService {
user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
@@ -202,105 +185,6 @@ export class UsersService {
);
}
async getSelfProfile(userId: string): Promise<AdministrativeUserView> {
return this.getById(userId);
}
async getSelfSignature(userId: string): Promise<UserReusableSignatureView> {
const [row] = (await this.dataSource.query(`
SELECT mime_type AS "mimeType", size_bytes AS "sizeBytes",
image_sha256 AS "imageSha256", updated_at AS "updatedAt"
FROM user_signature_profiles WHERE user_id=$1
`, [userId])) as Array<{
mimeType: 'image/png'; sizeBytes: number; imageSha256: string; updatedAt: Date;
}>;
return row ? { configured: true, ...row } : {
configured: false, mimeType: null, sizeBytes: null, imageSha256: null, updatedAt: null,
};
}
async getSelfSignatureContent(userId: string): Promise<{ buffer: Buffer; sha256: string }> {
const [row] = (await this.dataSource.query(`
SELECT image_data AS buffer, image_sha256 AS sha256
FROM user_signature_profiles WHERE user_id=$1
`, [userId])) as Array<{ buffer: Buffer; sha256: string }>;
if (!row) throw new NotFoundException({
code: 'USER_SIGNATURE_NOT_CONFIGURED',
message: 'Todavía no cargaste tu firma de inspector',
});
return row;
}
async updateSelfSignature(
file: UploadedInspectionSignatureFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<UserReusableSignatureView> {
if (!principal.roles.includes('inspector')) throw new ForbiddenException({
code: 'INSPECTOR_SIGNATURE_ROLE_REQUIRED',
message: 'La firma reutilizable está disponible para usuarios con rol Inspector',
});
const inspected = inspectInspectionSignatureFile(file);
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
const before = await this.getSelfSignature(principal.userId);
await this.dataSource.transaction(async (manager) => {
await manager.query(`
INSERT INTO user_signature_profiles (
user_id,original_name,mime_type,size_bytes,image_sha256,image_data,updated_by
) VALUES ($1,$2,$3,$4,$5,$6,$1)
ON CONFLICT (user_id) DO UPDATE SET
original_name=EXCLUDED.original_name, mime_type=EXCLUDED.mime_type,
size_bytes=EXCLUDED.size_bytes, image_sha256=EXCLUDED.image_sha256,
image_data=EXCLUDED.image_data, updated_by=EXCLUDED.updated_by,
updated_at=CURRENT_TIMESTAMP
`, [principal.userId, inspected.originalName, inspected.mimeType, file!.buffer.length, imageSha256, file!.buffer]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user_signature_profile',
entityId: principal.userId,
beforeData: before as unknown as Record<string, unknown>,
afterData: { configured: true, imageSha256, sizeBytes: file!.buffer.length },
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
}, manager);
});
return this.getSelfSignature(principal.userId);
}
async deleteSelfSignature(
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<UserReusableSignatureView> {
const before = await this.getSelfSignature(principal.userId);
await this.dataSource.transaction(async (manager) => {
await manager.query('DELETE FROM user_signature_profiles WHERE user_id=$1', [principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.USER_UPDATED,
entityType: 'user_signature_profile',
entityId: principal.userId,
beforeData: before as unknown as Record<string, unknown>,
afterData: { configured: false },
metadata: { scope: 'SELF_SIGNATURE', reusableForActs: true },
}, manager);
});
return this.getSelfSignature(principal.userId);
}
async updateSelfProfile(
dto: UpdateSelfProfileDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AdministrativeUserView> {
if (!dto.email?.trim()) throw userEmailRequired();
return this.update(
principal.userId,
{ email: dto.email, phone: dto.phone, jobTitle: dto.jobTitle },
principal,
request,
);
}
async create(
dto: CreateUserDto,
principal: AuthPrincipal,
@@ -311,10 +195,10 @@ export class UsersService {
try {
return await this.dataSource.transaction(async (manager) => {
const roles = await this.resolveRoles(manager, dto.roleIds);
if (!dto.email?.trim()) throw userEmailRequired();
this.assertInspectorHasEmail(roles, dto.email ?? null);
const user = manager.getRepository(User).create({
username: dto.username.trim().toLowerCase(),
email: dto.email.trim().toLowerCase(),
email: dto.email?.trim().toLowerCase() || null,
dni: dto.dni ?? null,
phone: dto.phone ?? null,
jobTitle: dto.jobTitle ?? null,
@@ -384,13 +268,15 @@ export class UsersService {
return await this.dataSource.transaction(async (manager) => {
const user = await this.lockUser(manager, id);
const before = await this.loadUserView(manager, id);
if (dto.email !== undefined && !dto.email) throw userEmailRequired();
if (dto.email !== undefined && !dto.email && before.roles.some((role) => role.code === 'inspector')) {
throw inspectorEmailRequired();
}
if (dto.username !== undefined) {
user.username = dto.username.trim().toLowerCase();
}
if (dto.email !== undefined) {
user.email = dto.email!.trim().toLowerCase();
user.email = dto.email?.trim().toLowerCase() || null;
}
if (dto.dni !== undefined) user.dni = dto.dni ?? null;
if (dto.phone !== undefined) user.phone = dto.phone ?? null;
@@ -400,13 +286,6 @@ export class UsersService {
if (dto.lastName !== undefined) user.lastName = dto.lastName.trim();
user.updatedBy = principal.userId;
await manager.getRepository(User).save(user);
if (dto.email !== undefined && user.email) {
await manager.query(`
UPDATE user_smtp_settings
SET from_email=$2,reply_to=$2,updated_by=$1,updated_at=CURRENT_TIMESTAMP
WHERE user_id=$1 AND mode='CUSTOM'
`, [id, user.email]);
}
const updated = await this.loadUserView(manager, id);
await this.audit.record(
@@ -535,6 +414,7 @@ export class UsersService {
await this.lockUser(manager, id);
const roles = await this.resolveRoles(manager, dto.roleIds);
const before = await this.loadUserView(manager, id);
this.assertInspectorHasEmail(roles, before.email);
const beforeIds = before.roles.map((role) => role.id).sort();
const afterIds = roles.map((role) => role.id).sort();
if (beforeIds.join(',') === afterIds.join(',')) return before;
@@ -573,6 +453,12 @@ export class UsersService {
return roles;
}
private assertInspectorHasEmail(roles: Role[], email: string | null | undefined): void {
if (roles.some((role) => role.code === 'inspector') && !email?.trim()) {
throw inspectorEmailRequired();
}
}
private async insertUserRoles(
manager: EntityManager,
userId: string,
@@ -625,7 +511,6 @@ export class UsersService {
user_account.password_changed_at AS "passwordChangedAt",
user_account.created_at AS "createdAt",
user_account.updated_at AS "updatedAt",
COALESCE((SELECT mode FROM user_smtp_settings WHERE user_id=user_account.id),'SYSTEM') AS "smtpMode",
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
@@ -1,407 +0,0 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
type DossierRecord = Record<string, any>;
@Injectable()
export class ActivityDossierService {
private readonly logger = new Logger(ActivityDossierService.name);
constructor(private readonly dataSource: DataSource) {}
async dossier(id: string): Promise<Record<string, unknown>> {
const assetRows = (await this.dataSource.query(
`SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt"
FROM assets
WHERE id = $1`,
[id],
)) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>;
const asset = assetRows[0];
if (!asset) {
throw new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
});
}
const failedFacets: string[] = [];
const safeQuery = async (facet: string, sql: string): Promise<DossierRecord[]> => {
try {
return (await this.dataSource.query(sql, [id])) as DossierRecord[];
} catch (error) {
failedFacets.push(facet);
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`No se pudo cargar la faceta ${facet} del expediente ${id}: ${message}`);
return [];
}
};
const [
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
media,
versions,
] = await Promise.all([
safeQuery('visits', `
SELECT visit.id, visit.code, visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
visit.created_at AS "createdAt"
FROM inspection_visits visit
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_visit_assets visit_asset
WHERE visit_asset.visit_id = visit.id
AND visit_asset.asset_id = $1
AND visit_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_act_assets act_asset
ON act_asset.act_id = act.id AND act_asset.included = true
WHERE act.visit_id = visit.id
AND act_asset.asset_id = $1
)
OR EXISTS (
SELECT 1
FROM inspection_acts act
JOIN inspection_findings finding ON finding.act_id = act.id
WHERE act.visit_id = visit.id
AND finding.asset_id = $1
)
ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC
LIMIT 200
`),
safeQuery('acts', `
SELECT act.id, act.visit_id AS "visitId", act.code, act.status,
act.occurred_at AS "occurredAt", act.title, act.summary,
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
visit.code AS "visitCode"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY act.occurred_at DESC
LIMIT 200
`),
safeQuery('findings', `
SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status,
finding.title, finding.description,
finding.correction_due_on AS "correctionDueOn",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.next_control_on AS "nextControlOn",
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.asset_id = $1
ORDER BY finding.created_at DESC
LIMIT 500
`),
safeQuery('evidence', `
SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId",
evidence.kind, evidence.purpose, evidence.original_name AS "originalName",
evidence.title, evidence.description, evidence.captured_at AS "capturedAt",
evidence.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_evidence evidence
JOIN inspection_findings finding ON finding.id = evidence.finding_id
WHERE finding.asset_id = $1
ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC
LIMIT 500
`),
safeQuery('communications', `
SELECT communication.id, communication.finding_id AS "findingId",
communication.direction, communication.channel, communication.type,
communication.occurred_at AS "occurredAt", communication.subject,
communication.details, communication.contact_name AS "contactName",
communication.created_at AS "createdAt", finding.code AS "findingCode",
finding.title AS "findingTitle"
FROM inspection_finding_communications communication
JOIN inspection_findings finding ON finding.id = communication.finding_id
WHERE finding.asset_id = $1
ORDER BY communication.occurred_at DESC
LIMIT 500
`),
safeQuery('verificationResults', `
SELECT verification_link.id,
verification_link.finding_id AS "findingId",
verification_link.visit_id AS "visitId",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle",
visit.code AS "visitCode", visit.status AS "visitStatus",
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id
AND verification_evidence.verification_visit_id = visit.id
AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount"
FROM inspection_finding_verification_visits verification_link
JOIN inspection_findings finding ON finding.id = verification_link.finding_id
JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL
ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC
LIMIT 500
`),
safeQuery('documents', `
SELECT document.id, document.document_type AS "documentType",
document.document_number AS "documentNumber", document.title,
document.issuer, document.document_date AS "documentDate",
document.external_reference AS "externalReference",
link.relation_type AS "relationType", link.notes,
link.created_at AS "linkedAt"
FROM asset_source_documents link
JOIN source_documents document ON document.id = link.document_id
WHERE link.asset_id = $1
ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC
LIMIT 300
`),
safeQuery('inspectionReports', `
SELECT report.id, report.code, report.status,
report.pdf_status AS "pdfStatus", report.title,
report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256",
act.id AS "actId", act.code AS "actCode",
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_reports report
JOIN inspection_acts act ON act.id = report.act_id
JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE visit.scope_asset_id = $1
OR EXISTS (
SELECT 1
FROM inspection_act_assets act_asset
WHERE act_asset.act_id = act.id
AND act_asset.asset_id = $1
AND act_asset.included = true
)
OR EXISTS (
SELECT 1
FROM inspection_findings finding
WHERE finding.act_id = act.id
AND finding.asset_id = $1
)
ORDER BY report.generated_at DESC
LIMIT 200
`),
safeQuery('media', `
SELECT media.id, media.kind, media.original_name AS "originalName",
media.title, media.description, media.captured_at AS "capturedAt",
media.created_at AS "createdAt", media.source
FROM asset_media media
WHERE media.asset_id = $1 AND media.deleted_at IS NULL
ORDER BY COALESCE(media.captured_at, media.created_at) DESC
LIMIT 500
`),
safeQuery('versions', `
SELECT version.id, version.version_number AS "versionNumber",
version.change_type AS "changeType", version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername",
version.source
FROM asset_versions version
WHERE version.asset_id = $1
ORDER BY version.occurred_at DESC
LIMIT 500
`),
]);
const timeline: DossierRecord[] = [];
const push = (event: DossierRecord) => timeline.push(event);
versions.forEach((version) => push({
id: `version:${String(version.id)}`,
kind: 'INVENTORY_CHANGE',
occurredAt: version.occurredAt,
title: version.changeType === 'CREATED' || version.changeType === 'BASELINE'
? 'Registro incorporado al inventario'
: 'Inventario actualizado',
description: Array.isArray(version.changedFields) && version.changedFields.length > 0
? `Campos: ${(version.changedFields as string[]).join(', ')}`
: null,
meta: {
versionNumber: version.versionNumber,
changeType: version.changeType,
actorUsername: version.actorUsername,
source: version.source,
},
}));
visits.forEach((visit) => push({
id: `visit:${String(visit.id)}`,
kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`,
description: null,
href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status },
}));
acts.forEach((act) => push({
id: `act:${String(act.id)}`,
kind: 'ACT',
occurredAt: act.occurredAt,
title: `Acta ${String(act.code)}`,
description: act.title,
href: `/inspecciones/actas/${String(act.id)}`,
meta: { status: act.status, visitCode: act.visitCode },
}));
findings.forEach((finding) => {
push({
id: `finding:${String(finding.id)}`,
kind: 'FINDING',
occurredAt: finding.createdAt,
title: `Hallazgo ${String(finding.code)}`,
description: finding.title,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status, actCode: finding.actCode },
});
if (finding.closedAt) {
push({
id: `finding-close:${String(finding.id)}`,
kind: 'FINDING_CLOSED',
occurredAt: finding.closedAt,
title: `Hallazgo ${String(finding.code)} cerrado`,
description: finding.closureNotes,
href: `/hallazgos/${String(finding.id)}`,
meta: { status: finding.status },
});
}
});
verificationResults.forEach((verification) => push({
id: `verification:${String(verification.id)}`,
kind: 'VERIFICATION',
occurredAt: verification.verifiedAt ?? verification.resultRecordedAt,
title: verification.outcome === 'RESOLVED'
? `Verificación conforme · ${String(verification.findingCode)}`
: verification.outcome === 'NOT_RESOLVED'
? `Verificación no conforme · ${String(verification.findingCode)}`
: `Verificación reprogramada · ${String(verification.findingCode)}`,
description: verification.resultNotes,
href: `/hallazgos/${String(verification.findingId)}`,
meta: {
outcome: verification.outcome,
visitCode: verification.visitCode,
targetControlOn: verification.targetControlOn,
rescheduledControlOn: verification.rescheduledControlOn,
evidenceCount: verification.evidenceCount,
},
}));
communications.forEach((communication) => push({
id: `communication:${String(communication.id)}`,
kind: 'COMMUNICATION',
occurredAt: communication.occurredAt,
title: communication.type === 'COMPANY_RESPONSE'
? 'Respuesta de la empresa'
: String(communication.subject),
description: communication.details,
href: `/hallazgos/${String(communication.findingId)}`,
meta: {
findingCode: communication.findingCode,
direction: communication.direction,
channel: communication.channel,
},
}));
evidence.forEach((item) => push({
id: `evidence:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado',
description: item.title ?? item.originalName,
href: `/hallazgos/${String(item.findingId)}`,
meta: { findingCode: item.findingCode, purpose: item.purpose },
}));
inspectionReports.forEach((report) => push({
id: `inspection-report:${String(report.id)}`,
kind: 'REPORT',
occurredAt: report.generatedAt,
title: `Informe ${String(report.code)}`,
description: report.title,
href: `/informes/${String(report.id)}`,
meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode },
}));
documents.forEach((document) => push({
id: `source-document:${String(document.id)}`,
kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT',
occurredAt: document.documentDate ?? document.linkedAt,
title: String(document.title),
description: document.documentNumber
? `Documento ${String(document.documentNumber)}`
: 'Documento vinculado al inventario',
meta: { documentType: document.documentType, relationType: document.relationType },
}));
media.forEach((item) => push({
id: `asset-media:${String(item.id)}`,
kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT',
occurredAt: item.capturedAt ?? item.createdAt,
title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario',
description: item.title ?? item.originalName,
meta: { source: item.source },
}));
timeline.sort(
(a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime(),
);
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
return {
asset: {
id: asset.id,
code: asset.code,
name: asset.name,
commonName: asset.commonName,
},
counters: {
inspections: visits.length,
acts: acts.length,
findings: findings.length,
verifications: verificationResults.length,
openFindings,
closedFindings,
evidence: evidence.length,
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
photos: evidence.filter((item) => item.kind === 'PHOTO').length
+ media.filter((item) => item.kind === 'PHOTO').length,
reports: reports.length + inspectionReports.length,
},
visits,
acts,
findings,
evidence,
communications,
verificationResults,
documents,
inspectionReports,
reports,
media,
versions,
timeline: timeline.slice(0, 500),
warnings: failedFacets,
};
}
}
@@ -260,7 +260,7 @@ export const MASTER_BOOTSTRAP_TYPES: MasterBootstrapTypePreset[] = [
{
code: 'equipo',
name: 'Equipo',
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en un tipo de instalación/subinstalación específico.',
description: 'Equipo físico genérico. Se conserva como alternativa cuando el inventario no permita clasificarlo todavía en una familia técnica específica.',
canBeRoot: false,
operationalRole: AssetTypeOperationalRole.GENERIC,
allowedParentCodes: ['instalacion', 'planta', 'bateria', 'estacion', 'subestacion', 'pozo', 'zona_bombas', 'sistema_defensa_incendios', 'cargadero_descargadero'],
@@ -24,15 +24,10 @@ import { AssetRegistryService } from './asset-registry.service';
import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service';
import { InventoryStructureController } from './inventory-structure.controller';
import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-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 { ActivityDossierService } from './activity-dossier.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@@ -44,7 +39,6 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetsController,
InventoryStructureController,
InventoryFamilyCatalogController,
InventoryTechnicalValuesController,
InventoryBrowserController,
InventoryMergeController,
FieldInventoryMergeController,
@@ -61,13 +55,9 @@ import { InventoryBrowserService } from './inventory-browser.service';
AssetTypesService,
AssetsService,
InventoryStructureService,
SimpleInventoryStructureService,
InventoryFamilyCatalogService,
InventoryTechnicalValuesService,
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
ActivityDossierService,
MergedInventoryDossierService,
AssetGeometriesService,
AssetHistoryService,
@@ -142,8 +142,27 @@ export class AssetOperationalRelationsService {
if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' });
}
// F7: un Área puede estar explotada por varias Empresas simultáneamente.
// La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área.
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
@@ -151,6 +170,20 @@ export class AssetOperationalRelationsService {
RETURNING id
`, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>;
// Compatibility snapshot only. This does not move or re-parent Inventory.
// area_company_relations remains the temporal source of truth.
if (dto.relationRole === AreaOrganizationRole.OPERATOR) {
await manager.query(`
UPDATE assets asset
SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid
FROM asset_types type
WHERE type.id=asset.asset_type_id
AND type.operational_role='GENERIC'
AND asset.operational_area_id=$1::uuid
AND asset.operator_company_id IS DISTINCT FROM $2::uuid
`,[dto.areaId,dto.companyId,principal.userId]);
}
const created = await this.loadRelation(manager, row.id);
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -159,7 +192,7 @@ export class AssetOperationalRelationsService {
entityId: row.id,
afterData: this.auditView(created),
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true }
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
: undefined,
}, manager);
return created;
@@ -190,7 +223,8 @@ export class AssetOperationalRelationsService {
});
}
// Ending an operator relation never moves, rewrites or blocks existing Inventory.
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
// Ending an operator relation must never be blocked by existing Inventory.
await manager.query(`
UPDATE area_company_relations
SET valid_until = CURRENT_TIMESTAMP,
@@ -209,7 +243,6 @@ export class AssetOperationalRelationsService {
afterData: this.auditView(updated),
metadata: {
inventoryHierarchyChanged:false,
operatorSnapshotPreserved:true,
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
},
}, manager);
@@ -137,41 +137,21 @@ 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 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"
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany"
FROM asset_context_history history
LEFT JOIN assets parent ON parent.id=history.parent_id
LEFT JOIN assets area ON area.id=history.operational_area_id
LEFT JOIN 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
LEFT JOIN assets company ON company.id=history.operator_company_id
WHERE history.asset_id=$1
AND history.valid_from <= $2
AND (history.valid_until IS NULL OR history.valid_until > $2)
ORDER BY history.valid_from DESC
LIMIT 1
`, [assetId, asOf])) as Array<{
parent: Record<string, unknown> | null;
operationalArea: Record<string, unknown> | null;
operatorCompany: Record<string, unknown> | null;
}>;
`, [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,
@@ -756,12 +756,10 @@ export class AssetsService {
const definitions = await this.loadDefinitions(manager, type.id);
const values = validateAssetAttributeValues(definitions, dto.attributes);
const asset = manager.getRepository(Asset).create({
id: dto.clientGeneratedId,
assetTypeId: type.id,
parentId: dto.parentId,
operationalAreaId: dto.operationalAreaId,
operatorCompanyId: dto.operatorCompanyId,
inventoryFamilyId: dto.inventoryFamilyId ?? null,
code: dto.code,
name: dto.name,
commonName: dto.commonName ?? null,
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer';
import { IsDate, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator';
export class ChangeAssetContextDto {
@IsOptional()
@@ -10,13 +10,8 @@ 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()
@IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' })
@IsUUID('4')
operatorCompanyId?: string | null;
@IsOptional()
@@ -54,10 +54,6 @@ 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,
@@ -2,10 +2,6 @@ import { Transform } from 'class-transformer';
import { IsObject, IsOptional, IsString, IsUUID, Matches, MaxLength, MinLength } from 'class-validator';
export class CreateFieldDiscoveryDto {
@IsOptional()
@IsUUID('4')
clientGeneratedId?: string;
@IsUUID('4')
visitId!: string;
@@ -40,10 +36,6 @@ export class CreateFieldDiscoveryDto {
@IsUUID('4')
operatorCompanyId!: string;
@IsOptional()
@IsUUID('4')
inventoryFamilyId?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@@ -9,12 +9,8 @@ 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',
@@ -53,14 +49,6 @@ 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()
@@ -22,10 +22,8 @@ export class CreateInventoryFamilyDto {
name!: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsUUID('4')
parentFamilyId?: string | null;
@IsOptional()
@IsArray()
@@ -44,10 +42,8 @@ export class UpdateInventoryFamilyDto {
name?: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(200)
@IsUUID('4', { each: true })
parentFamilyIds?: string[];
@IsUUID('4')
parentFamilyId?: string | null;
@IsOptional()
@IsArray()
@@ -1,103 +0,0 @@
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, IsEmpty, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class ListAssetTreeQueryDto {
@@ -24,12 +24,8 @@ 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()
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
@IsUUID('4')
operatorCompanyId?: string;
@IsOptional()
@@ -1,7 +1,6 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEmpty,
IsEnum,
IsInt,
IsOptional,
@@ -67,12 +66,7 @@ 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()
@IsEmpty({ message: 'Filtrá la Operadora mediante la relación temporal del Área, no mediante el snapshot del Inventario' })
@IsUUID('4')
operatorCompanyId?: string;
}
@@ -1,6 +1,5 @@
import { Transform } from 'class-transformer';
import {
IsEmpty,
IsObject,
IsOptional,
IsString,
@@ -49,13 +48,9 @@ export class UpdateAssetDto {
operationalAreaId?: string | null;
@IsOptional()
@IsEmpty({ message: 'La Empresa relacionada se administra en el Yacimiento, no desde la edición genérica del Inventario' })
@IsUUID('4')
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,
@@ -1,6 +0,0 @@
import { IsObject } from 'class-validator';
export class UpdateInventoryTechnicalValuesDto {
@IsObject()
values!: Record<string, unknown>;
}
@@ -13,16 +13,6 @@ export class InventoryBrowserController {
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);
@@ -16,9 +16,9 @@ export class InventoryBrowserService {
async items(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
'asset.is_inventory_instance=true',
"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);
@@ -36,21 +36,15 @@ export class InventoryBrowserService {
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.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::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
)
conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=asset.operational_area_id
AND relation.company_id=${company}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)`);
}
@@ -82,8 +76,7 @@ export class InventoryBrowserService {
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)
WHERE relation.area_id=asset.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
@@ -91,8 +84,7 @@ export class InventoryBrowserService {
) 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",
0::integer AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType",
(SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount",
@@ -112,14 +104,7 @@ export class InventoryBrowserService {
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
ORDER BY asset.name,asset.code
LIMIT ${limit} OFFSET ${offsetParam}
`,params);
@@ -134,30 +119,28 @@ export class InventoryBrowserService {
};
}
async departments(query: InventoryBrowserQueryDto) {
async areas(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"lower(type.code)='departamento'",
"department.information_status<>'INACTIVE'",
"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(`(department.code ILIKE ${p} OR department.name ILIKE ${p} OR COALESCE(department.common_name,'') ILIKE ${p})`);
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 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'
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
@@ -166,86 +149,41 @@ export class InventoryBrowserService {
const data = await this.dataSource.query(`
SELECT
department.id,department.code,department.name,department.common_name AS "commonName",
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,
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
area.information_status AS "informationStatus",
area.operational_status AS "operationalStatus",
(
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=area.id
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
LIMIT 1
) AS "currentOperator",
(
SELECT COUNT(*)::integer
FROM assets yacimiento
JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=area.id
AND lower(ytype.code)='yacimiento'
AND yacimiento.information_status<>'INACTIVE'
) AS "yacimientoCount",
(
SELECT COUNT(*)::integer
FROM assets inventory
WHERE inventory.is_inventory_instance=true
AND inventory.information_status<>'INACTIVE'
AND inventory.operational_area_id=area.id
) AS "inventoryCount"
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY area.name,area.code
`, params);
return { data, meta: { count: data.length } };
}
@@ -261,6 +199,9 @@ export class InventoryBrowserService {
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') {
conditions.push('asset.is_inventory_instance=true');
}
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const p = `$${params.length}`;
@@ -282,12 +223,20 @@ export class InventoryBrowserService {
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",
(
SELECT COUNT(*)::integer
FROM assets child
WHERE child.parent_id=asset.id
AND child.information_status<>'INACTIVE'
AND (
lower(type.code)='area'
OR child.is_inventory_instance=true
OR EXISTS (
SELECT 1 FROM asset_types child_type
WHERE child_type.id=child.asset_type_id AND lower(child_type.code)='yacimiento'
)
)
) AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
asset.updated_at AS "updatedAt"
FROM assets asset
@@ -314,10 +263,10 @@ export class InventoryBrowserService {
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())) {
if (!['area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
throw new BadRequestException({
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
message:'La navegación de Inventarios admite Departamento → Área → Yacimiento → Instalación → Subinstalación',
message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación',
});
}
return parent;
@@ -325,7 +274,6 @@ export class InventoryBrowserService {
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';
@@ -17,10 +17,6 @@ import {
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')
@@ -54,35 +50,6 @@ export class InventoryFamilyCatalogController {
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(
@@ -15,13 +15,7 @@ import type {
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;
@@ -30,24 +24,12 @@ type FamilyRow = {
informationLabels: string[];
sourceReference: string | null;
isActive: boolean;
parentFamilyIds: string[];
parentFamilies: FamilyParent[];
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
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()
@@ -58,7 +40,27 @@ export class InventoryFamilyCatalogService {
) {}
async listAdmin() {
const data = await this.dataSource.query(this.familySelect(`ORDER BY family.level,family.is_active DESC,family.name,family.code`)) as FamilyRow[];
const data = await this.dataSource.query(`
SELECT
family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((
SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id)
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
WHERE mapping.inventory_family_id=family.id
),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
ORDER BY family.level,family.is_active DESC,
COALESCE(parent.name,''),family.name,family.code
`) as FamilyRow[];
return { data };
}
@@ -78,28 +80,27 @@ export class InventoryFamilyCatalogService {
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 parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null);
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
const [inserted] = (await manager.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F6',true)
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true)
RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear el tipo de Inventario');
await this.replaceParents(manager, inserted.id, parentIds);
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[inserted.id,parentId]);
}
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
@@ -107,7 +108,7 @@ export class InventoryFamilyCatalogService {
entityType: 'inventory_family',
entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F6' },
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' },
},manager);
return created;
});
@@ -121,13 +122,10 @@ export class InventoryFamilyCatalogService {
) {
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);
}
const nextParentId = dto.parentFamilyId === undefined
? before.parentFamilyId
: dto.parentFamilyId;
const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId);
await manager.query(`
UPDATE inventory_families SET
name=COALESCE($2::varchar,name),
@@ -141,7 +139,15 @@ export class InventoryFamilyCatalogService {
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null,
]);
if (dto.parentFamilyIds !== undefined) await this.replaceParents(manager, familyId, parentIds);
if (before.level==='SUBINSTALLATION') {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[familyId,parentId]);
}
}
const after = await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
@@ -156,85 +162,6 @@ export class InventoryFamilyCatalogService {
});
}
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 el tipo',
});
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,
@@ -251,10 +178,12 @@ export class InventoryFamilyCatalogService {
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',
});
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]);
@@ -269,9 +198,11 @@ export class InventoryFamilyCatalogService {
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},
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);
});
@@ -286,160 +217,83 @@ export class InventoryFamilyCatalogService {
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
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(`
const rows=(await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lockClause}
`),[familyId])) as FamilyRow[];
${lock ? 'FOR UPDATE OF family' : ''}
`,[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'El tipo de Inventario no existe',
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
});
return rows[0];
}
private async validateParents(
private async validateParent(
manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION',
requestedIds:string[],
parentFamilyId:string|null,
ownId:string|null,
):Promise<string[]> {
const ids=[...new Set(requestedIds)];
):Promise<string|null> {
if (level==='INSTALLATION') {
if (ids.length) throw new BadRequestException({
if (parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Un tipo de Instalación no lleva compatibilidades padre',
message:'Una clasificación de Instalación no tiene clasificación padre',
});
return [];
return null;
}
if (!ids.length) throw new BadRequestException({
if (!parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Elegí al menos un tipo de Instalación compatible con esta Subinstalación',
message:'Una Subinstalación debe pertenecer a un tipo de Instalación',
});
if (ownId && ids.includes(ownId)) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Un tipo no puede ser compatible consigo mismo',
if (parentFamilyId===ownId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
});
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',
SELECT id FROM inventory_families
WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true
LIMIT 1
`,[parentFamilyId])) as Array<{id:string}>;
if (!rows[0]) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_INVALID',
message:'La Subinstalación debe vincularse a una clasificación de Instalación activa',
});
return values;
return parentFamilyId;
}
private cleanLabels(labels:string[]):string[] {
const unique=new Map<string,string>();
for (const raw of labels) {
const clean=raw.trim(); if (!clean) continue;
const 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:'El tipo admite hasta 100 campos de información',
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');
}
}
@@ -4,14 +4,10 @@ import { RequirePermissions } from '../authorization/decorators/require-permissi
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto';
import { InventoryStructureService } from './inventory-structure.service';
import { SimpleInventoryStructureService } from './simple-inventory-structure.service';
@Controller('inventory-structure')
export class InventoryStructureController {
constructor(
private readonly inventoryStructure: InventoryStructureService,
private readonly simpleInventoryStructure: SimpleInventoryStructureService,
) {}
constructor(private readonly inventoryStructure: InventoryStructureService) {}
@Get()
@RequirePermissions('assets.read')
@@ -28,17 +24,6 @@ export class InventoryStructureController {
return this.inventoryStructure.parents(kind, search);
}
@Post('simple')
@RequirePermissions('assets.create')
createSimple(
@Body() dto: CreateInventoryStructureDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.simpleInventoryStructure.create(dto, principal, request);
}
// Compatibilidad temporal con el alta anterior. La WEB F7 utiliza /simple.
@Post()
@RequirePermissions('assets.create')
create(
@@ -20,8 +20,6 @@ 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;
@@ -29,8 +27,9 @@ type FamilyRow = {
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null;
informationLabels: string[];
parentFamilyIds: string[];
parentFamilies: FamilyParent[];
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
};
type ParentRow = {
id: string;
@@ -38,19 +37,9 @@ type ParentRow = {
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type IdRow = { id: string };
type YacimientoContext = {
companyId: string;
concessionTypeId: string;
concessionName: string;
};
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
DEPARTAMENTO: 'departamento',
AREA: 'area',
YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion',
@@ -58,8 +47,7 @@ const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, stri
};
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null,
DEPARTAMENTO: null,
AREA: 'departamento',
AREA: null,
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion',
@@ -82,24 +70,22 @@ export class InventoryStructureService {
SELECT id,code,name
FROM asset_types
WHERE (
lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion')
lower(code) IN ('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
WHEN lower(code)='area' THEN 1
WHEN lower(code)='yacimiento' THEN 2
WHEN lower(code)='instalacion' THEN 3
WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END
`)) as StructureTypeRow[];
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) {
if (!company || !area || !yacimiento || !instalacion || !subinstalacion) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
@@ -110,47 +96,24 @@ export class InventoryStructureService {
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels",
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"
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.is_active=true
ORDER BY family.level,family.name,family.code
`)) as FamilyRow[];
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 {
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: 'AREA', label: 'Área', type: area, parentKind: null, 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'),
};
@@ -158,7 +121,7 @@ export class InventoryStructureService {
async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'DEPARTAMENTO' || kind === 'EMPRESA') {
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre',
@@ -190,7 +153,7 @@ export class InventoryStructureService {
AND asset.information_status<>'INACTIVE'
${searchSql}
ORDER BY asset.name,asset.code
LIMIT 100
LIMIT 80
`, parameters);
return { data: rows };
}
@@ -205,43 +168,23 @@ 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 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 operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null;
const inserted = (await manager.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id,
asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,
code,name,common_name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by
) VALUES (
$1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::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
$1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
$5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
$11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
concessionTypeId,
family?.id ?? null,
generatedCode,
dto.name,
@@ -250,13 +193,9 @@ export class InventoryStructureService {
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Estructura manual de Inventario',
dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family
? `Tipo: ${family.code} · ${family.name}`
: yacimientoContext
? `Tipo de concesión: ${yacimientoContext.concessionName}`
: null,
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = inserted[0]?.id;
@@ -269,52 +208,52 @@ export class InventoryStructureService {
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 (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [
id,parent?.id ?? null,operationalAreaId,operatorCompanyId,
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,
id,
parent?.id ?? null,
operationalAreaId,
dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5',
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,
operatorCompanyId,
concessionTypeId,
versionNumber,
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorOwnership: false,
},
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS',message: 'Ya existe un registro 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;
}
}
@@ -325,9 +264,12 @@ export class InventoryStructureService {
: `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`,
});
if (!rows[0]) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
message: `El nivel ${kind} no está configurado`,
});
}
return rows[0];
}
@@ -338,23 +280,25 @@ export class InventoryStructureService {
): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) {
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',
});
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 Área 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"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
@@ -362,72 +306,17 @@ 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) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación',
});
if (parent.typeCode.toLowerCase() !== expectedType) {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_INVALID',
message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación',
});
}
return parent;
}
private async 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_YACIMIENTO_COMPANY_REQUIRED',
message: 'Seleccioná la Empresa relacionada del Yacimiento',
});
}
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
@@ -441,10 +330,12 @@ export class InventoryStructureService {
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',
});
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;
}
@@ -458,93 +349,49 @@ export class InventoryStructureService {
if (!expectedLevel) {
if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Empresa, Departamento, Área y Yacimiento no llevan Tipo de instalación/subinstalación',
message: 'Empresa, Área y Yacimiento no llevan familia técnica',
});
return null;
}
if (!familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED',
message: `Elegí el tipo de la ${kind.toLowerCase()}`,
message: `Elegí la familia técnica de la ${kind.toLowerCase()}`,
});
const rows = (await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",family.information_labels AS "informationLabels",
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"
family.legacy_type_code AS "legacyTypeCode",
family.information_labels AS "informationLabels",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid AND family.is_active=true
LIMIT 1
`, [familyId])) as FamilyRow[];
const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El Tipo de instalación/subinstalación no existe' });
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' });
if (family.level !== expectedLevel) throw new BadRequestException({
code: 'INVENTORY_FAMILY_LEVEL_INVALID',
message: 'El Tipo de instalación/subinstalación no corresponde al nivel seleccionado',
message: 'La familia técnica no corresponde al nivel seleccionado',
});
if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({
code:'INVENTORY_PARENT_FAMILY_REQUIRED',
message:'La Instalación padre debe tener un Tipo de instalación válido',
});
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({
if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) {
throw new BadRequestException({
code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID',
message: 'Ese tipo de Subinstalación no es compatible con el tipo de la Instalación seleccionada',
message: 'La Subinstalación elegida no pertenece a la familia 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 === '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';
const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
const readable = name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'REGISTRO';
return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120);
}
@@ -555,8 +402,6 @@ export class InventoryStructureService {
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
@@ -566,12 +411,11 @@ export class InventoryStructureService {
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]);
return rows[0];
}
}
type IdRow = { id: string };
@@ -1,28 +0,0 @@
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);
}
}
@@ -1,118 +0,0 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { 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 al tipo 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 Tipo de instalación/subinstalación 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,13 +1,10 @@
import { Injectable } from '@nestjs/common';
import { ActivityDossierService } from './activity-dossier.service';
import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>;
const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']);
function dedupeById<T extends LooseRecord>(items: T[]): T[] {
const seen = new Set<string>();
const result: T[] = [];
@@ -32,22 +29,11 @@ function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[
export class MergedInventoryDossierService {
constructor(
private readonly assets: AssetsService,
private readonly activity: ActivityDossierService,
private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService,
) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
const requestedAsset = await this.assets.getById(requestedAssetId);
const requestedTypeCode = requestedAsset.type.code.trim().toLowerCase();
// La conciliación/fusión existe sólo para Instalaciones y Subinstalaciones.
// Los registros territoriales (Departamento, Área, Yacimiento) deben poder
// abrir su Actividad sin depender del subsistema de merge.
if (!MERGEABLE_DOSSIER_TYPES.has(requestedTypeCode)) {
return await this.activity.dossier(requestedAssetId);
}
const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord;
const canonical = mergeStatus.canonical as LooseRecord;
const requested = mergeStatus.requested as LooseRecord;
@@ -58,7 +44,7 @@ export class MergedInventoryDossierService {
.filter((value, index, all) => all.indexOf(value) === index);
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.activity.dossier(assetId) as LooseRecord;
const dossier = await this.assets.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord;
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier };
@@ -159,9 +145,6 @@ export class MergedInventoryDossierService {
const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT');
const openFindings = findings.filter((finding) => finding.status === 'OPEN').length;
const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length;
const warnings = dossiers.flatMap(({ assetId, dossier }) =>
((dossier.warnings ?? []) as string[]).map((facet) => `${assetId}:${facet}`),
);
return {
asset: {
@@ -211,7 +194,6 @@ export class MergedInventoryDossierService {
media,
versions,
timeline: timeline.slice(0, 1000),
warnings,
};
}
}
@@ -1,323 +0,0 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AssetDataOrigin,
AssetInformationStatus,
AssetOperationalStatus,
AssetVersionChangeType,
AuditAction,
} from '../database/entities';
import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto';
import { AssetHistoryService } from './asset-history.service';
type StructureTypeRow = { id: string; code: string; name: string };
type ParentRow = {
id: string;
code: string;
name: string;
typeCode: string;
inventoryFamilyId: string | null;
operationalAreaId: string | null;
operatorCompanyId: string | null;
};
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
};
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> = {
EMPRESA: null,
DEPARTAMENTO: null,
AREA: 'departamento',
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
SUBINSTALACION: 'instalacion',
};
@Injectable()
export class SimpleInventoryStructureService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async create(
dto: CreateInventoryStructureDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
try {
return await this.dataSource.transaction(async (manager) => {
const type = await this.requireStructureType(manager, dto.kind);
const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null);
const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent);
let operationalAreaId: string | null = null;
let operatorCompanyId: string | null = null;
if (dto.kind === 'YACIMIENTO') {
if (!parent) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_AREA_REQUIRED', message: 'Seleccioná el Área del Yacimiento' });
if (!dto.operatorCompanyId) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED', message: 'Seleccioná la Empresa operadora del Yacimiento' });
await this.requireAreaCompanyRelation(manager, parent.id, dto.operatorCompanyId);
operationalAreaId = parent.id;
operatorCompanyId = dto.operatorCompanyId;
} else if (dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') {
operationalAreaId = parent?.operationalAreaId ?? null;
operatorCompanyId = parent?.operatorCompanyId ?? null;
if (!operationalAreaId || !operatorCompanyId) {
throw new ConflictException({
code: 'INVENTORY_YACIMIENTO_CONTEXT_MISSING',
message: 'El Yacimiento de origen debe tener Área y Empresa operadora válidas',
});
}
} else if (dto.operatorCompanyId || dto.concessionTypeId) {
throw new BadRequestException({
code: 'INVENTORY_SIMPLE_CONTEXT_NOT_ALLOWED',
message: 'Empresa operadora sólo corresponde al Yacimiento',
});
}
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const rows = (await manager.query(`
INSERT INTO assets (
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,NULL,$5::uuid,
$6::varchar,$7::varchar,NULL,NULL,$8::asset_information_status,$9::asset_operational_status,
$10::varchar,$11::varchar,$12::varchar,$13::text,$14::uuid,$14::uuid,$14::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
family?.id ?? null,
generatedCode,
dto.name,
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Alta simple de Inventario',
`inventory-simple:${dto.kind.toLowerCase()}`,
family ? `Tipo: ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = rows[0]?.id;
if (!id) throw new Error('No se pudo crear el registro');
if (dto.kind === 'EMPRESA') {
await manager.query(`
INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by)
VALUES ($1::uuid,'COMPANY',$2,$3::uuid)
ON CONFLICT (asset_id) DO UPDATE
SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP
`, [id, dto.name, principal.userId]);
}
const versionNumber = await this.history.capture(
manager,
id,
AssetVersionChangeType.CREATED,
principal,
request,
);
await manager.query(`
INSERT INTO asset_context_history (
asset_id,parent_id,operational_area_id,operator_company_id,valid_from,
change_reason,asset_version_number,source,request_id,created_by
) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8)
`, [
id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
dto.kind === 'YACIMIENTO'
? 'Alta de Yacimiento con Empresa operadora vinculada al Área'
: 'Alta simple 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,
afterData: created as Record<string, unknown>,
metadata: {
versionNumber,
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorCompanyId,
simpleInventoryFlow: true,
},
}, manager);
return created;
});
} catch (error) {
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 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];
}
private async requireParent(
manager: EntityManager,
kind: InventoryStructureKind,
parentId: string | null,
): Promise<ParentRow | null> {
const expectedType = PARENT_TYPE_BY_KIND[kind];
if (!expectedType) {
if (parentId) throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', message: `${kind === 'EMPRESA' ? 'Empresa' : 'Departamento'} no admite padre` });
return null;
}
if (!parentId) throw new BadRequestException({ code: 'INVENTORY_PARENT_REQUIRED', message: `Seleccioná el nivel superior para crear ${kind.toLowerCase()}` });
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,lower(type.code) AS "typeCode",
asset.inventory_family_id AS "inventoryFamilyId",
CASE WHEN lower(type.code)='area' THEN asset.id ELSE asset.operational_area_id END AS "operationalAreaId",
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'
FOR KEY SHARE OF asset
`, [parentId])) as ParentRow[];
const parent = rows[0];
if (!parent || parent.typeCode !== expectedType) {
throw new BadRequestException({ code: 'INVENTORY_PARENT_INVALID', message: `El registro padre debe ser ${expectedType}` });
}
return parent;
}
private async requireAreaCompanyRelation(manager: EntityManager, areaId: string, companyId: string): Promise<void> {
const rows = await manager.query(`
SELECT 1
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
JOIN asset_types company_type ON company_type.id=company.asset_type_id
WHERE relation.area_id=$1::uuid
AND relation.company_id=$2::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND company.information_status<>'INACTIVE'
AND company_type.operational_role='COMPANY'
AND company_type.is_active=true
LIMIT 1
FOR KEY SHARE OF relation
`, [areaId, companyId]);
if (!rows[0]) {
throw new BadRequestException({
code: 'INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA',
message: 'La Empresa seleccionada no está vinculada como explotadora del Área',
});
}
}
private async requireFamily(
manager: EntityManager,
kind: InventoryStructureKind,
familyId: string | null,
parent: ParentRow | null,
): Promise<FamilyRow | null> {
if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') {
if (familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel no usa Tipo de Instalación/Subinstalación' });
return null;
}
if (!familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_REQUIRED', message: `Seleccioná el tipo de ${kind === 'INSTALACION' ? 'Instalación' : 'Subinstalación'}` });
const rows = (await manager.query(`
SELECT id,code,name,level::text AS level
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
LIMIT 1
FOR KEY SHARE
`, [familyId])) as FamilyRow[];
const family = rows[0];
if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El tipo seleccionado no existe o está oculto' });
const expected = kind === 'INSTALACION' ? 'INSTALLATION' : 'SUBINSTALLATION';
if (family.level !== expected) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', message: 'El tipo seleccionado no corresponde a este nivel' });
if (kind === 'SUBINSTALACION') {
if (!parent?.inventoryFamilyId) throw new BadRequestException({ code: 'INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un tipo configurado' });
const compatible = await manager.query(`
SELECT 1 FROM inventory_family_parent_rules
WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid
LIMIT 1
`, [family.id, parent.inventoryFamilyId]);
if (!compatible[0]) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', message: 'Ese tipo de Subinstalación no está habilitado dentro de la Instalación seleccionada' });
}
return family;
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
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);
}
private async loadView(manager: EntityManager, id: string) {
const rows = await manager.query(`
SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description,
asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent,
CASE WHEN 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 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",
asset.created_at AS "createdAt",asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent ON parent.id=asset.parent_id
LEFT JOIN 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 inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
return rows[0];
}
}
+5 -4
View File
@@ -118,9 +118,10 @@ async function main(): Promise<void> {
);
}
email = (await prompt.question('Email: ')).trim().toLowerCase();
email = (await prompt.question('Email (optional): ')).trim().toLowerCase();
if (
!email || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
email &&
(email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
) {
throw new Error('Email is invalid');
}
@@ -188,7 +189,7 @@ async function main(): Promise<void> {
OR ($2::text IS NOT NULL AND LOWER(email) = $2)
LIMIT 1
`,
[username, email],
[username, email || null],
)) as unknown[];
if (duplicate.length > 0) {
throw new Error('Username or email already exists');
@@ -201,7 +202,7 @@ async function main(): Promise<void> {
const user = manager.getRepository(User).create({
username,
email,
email: email || null,
passwordHash,
firstName,
lastName,
@@ -33,7 +33,6 @@ 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'])
@@ -55,9 +54,6 @@ 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;
@@ -103,8 +103,6 @@ export enum AuditAction {
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
SMTP_TEST_SENT = 'SMTP_TEST_SENT',
USER_SMTP_SETTINGS_UPDATED = 'USER_SMTP_SETTINGS_UPDATED',
USER_SMTP_TEST_SENT = 'USER_SMTP_TEST_SENT',
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
@@ -30,8 +30,6 @@ 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 })
@@ -71,8 +69,8 @@ export class InspectionAct extends TimestampedEntity {
@Column({ type: 'text', nullable: true })
observations!: string | null;
@Column({ name: 'urgency', type: 'varchar', length: 24, nullable: true })
urgency!: InspectionActUrgency | null;
@Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT })
urgency!: InspectionActUrgency;
@Column({ name: 'deadline_days', type: 'integer', nullable: true })
deadlineDays!: number | null;
+2 -2
View File
@@ -16,8 +16,8 @@ export class User extends TimestampedEntity {
@Column({ type: 'varchar', length: 80 })
username!: string;
@Column({ type: 'varchar', length: 320 })
email!: string;
@Column({ type: 'varchar', length: 320, nullable: true })
email!: string | null;
@Column({ type: 'varchar', length: 32, nullable: true })
dni!: string | null;
@@ -1,168 +0,0 @@
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.');
}
}
@@ -1,226 +0,0 @@
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)
`);
}
}
@@ -1,35 +0,0 @@
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)
)
`);
}
}
@@ -1,163 +0,0 @@
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]);
}
}
@@ -1,39 +0,0 @@
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()');
}
}
@@ -1,57 +0,0 @@
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'
)
`);
}
}
@@ -1,240 +0,0 @@
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;
}
}
@@ -1,173 +0,0 @@
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.',
);
}
}

Some files were not shown because too many files have changed in this diff Show More