From 35d4630581abf3893843b148491a2f752974ac97 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Tue, 8 Sep 2026 23:18:15 -0300 Subject: [PATCH] =?UTF-8?q?F5=20=C2=B7=20Inventario=20operativo,=20territo?= =?UTF-8?q?rio=20y=20cat=C3=A1logo=20autorizado=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): simplify inventory administration menu * fix(web): remove legacy imports and function catalog routes * fix(web): remove redundant inspections lifecycle legend * feat(inventory): distinguish physical instances from structural records * fix(dashboard): align inventory and act follow-up metrics * fix(web): align dashboard summary contract * fix(web): clarify dashboard act and report concepts * feat(inventory): mark field-created records as real instances * feat(inventory): map physical instance flag on asset entity * fix(inventory): keep field yacimientos structural * feat(inventory): classify future concrete instances at database level * fix(inventory): count only installation and subinstallation instances * feat(inventory): add authoritative F5 source snapshot * feat(inventory): preload authoritative territory model * feat(inventory): preload authoritative technical catalog * fix(findings): use only authoritative F5 family catalog * fix(inventory): preserve non-hierarchical operator snapshot compatibility * feat(inventory): add inventory-only asset filter * feat(inventory): add inventory-only tree filter * feat(inventory): add inventory browser query contract * feat(inventory): add area-owned inventory browser * feat(inventory): expose area-owned inventory browser * refactor(inventory): remove function catalog and add inventory browser * fix(inventory): make operator relation temporal and non-owning * feat(web): add inventory browser API client * feat(inventory): extend inventory browser filters * feat(inventory): add real inventory list endpoint logic * feat(inventory): expose real inventory list * feat(web): add real inventory list client * refactor(web): make inventory hierarchy area-owned * fix(web): show only real inventory instances * fix(web): style act follow-up tabs and F5 inventory context * fix(web): load F5 flow styles * fix(inventory): apply area-owned operational guard on F5 up * fix(inventory): treat company on asset as non-owning creation snapshot * fix(inventory): resolve field inventory by area hierarchy, not company ownership * fix(inventory): preserve custom catalog and apply authoritative universal findings * fix(inventory): harden authoritative catalog migration checks * fix(inventory): make authoritative territory preload safely reversible * feat(inventory): allow independent company master creation * fix(inventory): make guided creation area-owned and support companies * feat(web): expose independent company master in inventory setup * feat(web): create companies independently from physical inventory hierarchy * fix(inventory): merge by physical area and preserve sealed documents * test(inventory): lock F5 authoritative model and merge invariants * feat(inventory): add family administration DTOs * feat(inventory): administer installation and subinstallation classifications * feat(inventory): expose family classification administration * feat(web): add inventory classification administration API * fix(web): configure finding applicability by inventory classification * fix(web): redefine inventory configuration around hierarchy classifications and columns * chore(release): identify F5 inventory model * chore(release): bump API for F5 inventory model * test(release): expect F5 health metadata * chore(release): align WEB package with F5 inventory cut * chore(release): expose F5 WEB phase * test(dashboard): expect inspector activity and act follow-up metrics * test(dashboard): route F5 summary query mocks explicitly * ci: rehearse all migrations on clean PostGIS before merge * ci: prove F5 migrations revert and reapply cleanly * test(f5): align operational navigation contract * test(f5): align operator lifecycle with area-owned inventory * test(f5): make merge compatibility area-based * test(f5): distinguish literal and normalized yacimiento counts * test(f5): model normalized yacimiento collision explicitly * ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal * ci(f5): bypass irreversible historical reset in clean rehearsal * fix(f5): make territory SQL parameter types explicit * fix(f5): guarantee canonical inventory hierarchy before territory preload * ci(f5): include canonical hierarchy migration in rollback gate * fix(f5): type relation backup markers explicitly * fix(f5): make catalog SQL text parameter types explicit --- .github/workflows/ci.yml | 146 +++++ api-v3/package.json | 2 +- .../src/asset-master/asset-master.module.ts | 9 +- .../asset-operational-relations.service.ts | 104 +-- .../dto/create-inventory-structure.dto.ts | 1 + .../dto/inventory-browser-query.dto.ts | 63 ++ .../dto/inventory-family-admin.dto.ts | 71 ++ .../dto/list-asset-tree-query.dto.ts | 5 + .../asset-master/dto/list-assets-query.dto.ts | 6 +- .../inventory-browser.controller.ts | 28 + .../asset-master/inventory-browser.service.ts | 283 ++++++++ .../inventory-family-catalog.controller.ts | 57 +- .../inventory-family-catalog.service.ts | 300 ++++++++- .../asset-master/inventory-merge.service.ts | 106 ++- .../inventory-structure.service.ts | 129 ++-- api-v3/src/dashboard/dashboard.service.ts | 56 +- api-v3/src/database/entities/asset.entity.ts | 3 + ...87100000-f5-inventory-physical-instance.ts | 52 ++ ...150000-f5-canonical-inventory-hierarchy.ts | 192 ++++++ ...790087200000-f5-authoritative-territory.ts | 602 +++++++++++++++++ ...00-f5-operational-context-compatibility.ts | 182 ++++++ ...0000-f5-authoritative-inventory-catalog.ts | 606 ++++++++++++++++++ .../f3-finding-catalog-resolver.service.ts | 47 +- .../f3-field-inventory-structure.service.ts | 15 +- .../field-inventory.service.ts | 118 +++- .../f5-authoritative-inventory-source.ts | 64 ++ api-v3/src/version.ts | 4 +- .../unit/asset-navigation-d5-3-11-1.test.ts | 14 +- ...et-operational-relations-integrity.test.ts | 6 +- api-v3/test/unit/dashboard-service.test.ts | 31 +- .../unit/f3-1-chronological-merge.test.ts | 6 +- api-v3/test/unit/f4-health-metadata.test.ts | 6 +- ...5-inventory-authoritative-contract.test.ts | 143 +++++ web-v2/package.json | 2 +- web-v2/src/app/App.tsx | 4 - web-v2/src/config/version.ts | 2 +- web-v2/src/f5.css | 90 +++ .../features/assets/AssetHierarchyView.tsx | 311 ++++----- .../FindingCatalogTypeApplicabilityPanel.tsx | 129 ++-- web-v2/src/layout/AppLayout.tsx | 2 - web-v2/src/lib/dashboardF4Api.ts | 3 +- web-v2/src/lib/inventoryBrowserApi.ts | 106 +++ web-v2/src/lib/inventoryStructureApi.ts | 46 +- web-v2/src/main.tsx | 1 + web-v2/src/pages/AssetTypesPage.tsx | 309 ++++----- web-v2/src/pages/AssetsPage.tsx | 100 +-- web-v2/src/pages/DashboardPage.tsx | 25 +- web-v2/src/pages/InspectionVisitsPage.tsx | 2 - web-v2/src/pages/InventoryCreatePage.tsx | 68 +- 49 files changed, 3988 insertions(+), 669 deletions(-) create mode 100644 api-v3/src/asset-master/dto/inventory-browser-query.dto.ts create mode 100644 api-v3/src/asset-master/dto/inventory-family-admin.dto.ts create mode 100644 api-v3/src/asset-master/inventory-browser.controller.ts create mode 100644 api-v3/src/asset-master/inventory-browser.service.ts create mode 100644 api-v3/src/database/migrations/1790087100000-f5-inventory-physical-instance.ts create mode 100644 api-v3/src/database/migrations/1790087150000-f5-canonical-inventory-hierarchy.ts create mode 100644 api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts create mode 100644 api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts create mode 100644 api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts create mode 100644 api-v3/src/reference-data/f5-authoritative-inventory-source.ts create mode 100644 api-v3/test/unit/f5-inventory-authoritative-contract.test.ts create mode 100644 web-v2/src/f5.css create mode 100644 web-v2/src/lib/inventoryBrowserApi.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 392d46b..d3389af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,152 @@ 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 on clean PostGIS + run: | + set -Eeuo pipefail + cleanup() { + docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true + } + trap cleanup EXIT + cleanup + + docker compose --env-file .env.example up -d db + + # The historical production reset is a one-shot operational migration, + # not a bootstrap migration: it requires production data/configuration + # that cannot exist at its timestamp in a database rebuilt from zero. + # Prove the clean chain reaches that exact guard, then mark only that + # one-shot migration as already applied and continue the reproducible + # schema chain. The historical migration itself remains untouched. + bootstrap_log="$(mktemp)" + set +e + docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log" + bootstrap_status=${PIPESTATUS[0]} + set -e + if [ "$bootstrap_status" -eq 0 ]; then + echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2 + exit 1 + fi + grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || { + echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2 + exit 1 + } + rm -f "$bootstrap_log" + + docker compose --env-file .env.example exec -T db \ + psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' + DO $$ + DECLARE + reset_rows integer; + BEGIN + SELECT COUNT(*) INTO reset_rows + FROM typeorm_migrations + WHERE name = 'ResetProductionOperationalData1788652800000'; + + IF reset_rows <> 0 THEN + RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows; + END IF; + + INSERT INTO typeorm_migrations ("timestamp", name) + VALUES (1788652800000, 'ResetProductionOperationalData1788652800000'); + END $$; + SQL + + docker compose --env-file .env.example --profile tools run --rm migrate + + docker compose --env-file .env.example exec -T db \ + psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' + DO $$ + DECLARE + f5_migrations integer; + real_inventory integer; + source_areas integer; + source_yacimientos integer; + source_installations integer; + source_subinstallations integer; + source_findings integer; + BEGIN + SELECT COUNT(*) INTO f5_migrations + FROM typeorm_migrations + WHERE name IN ( + 'F5InventoryPhysicalInstance1790087100000', + 'F5CanonicalInventoryHierarchy1790087150000', + 'F5AuthoritativeTerritory1790087200000', + 'F5OperationalContextCompatibility1790087250000', + 'F5AuthoritativeInventoryCatalog1790087300000' + ); + IF f5_migrations <> 5 THEN + RAISE EXCEPTION 'Expected 5 F5 migrations, got %', f5_migrations; + END IF; + + SELECT COUNT(*) INTO real_inventory + FROM assets WHERE is_inventory_instance=true AND information_status<>'INACTIVE'; + IF real_inventory <> 0 THEN + RAISE EXCEPTION 'Fresh F5 database must start with 0 real Inventory instances, got %', real_inventory; + END IF; + + SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA'), + COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento') + INTO source_areas,source_yacimientos + FROM source_documents document + JOIN asset_source_documents link ON link.document_id=document.id + JOIN assets asset ON asset.id=link.asset_id + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE document.document_number='DH-F5-TERRITORY'; + IF source_areas <> 64 OR source_yacimientos <> 230 THEN + RAISE EXCEPTION 'F5 territory preload mismatch: areas %, yacimientos %', source_areas,source_yacimientos; + END IF; + + SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'), + COUNT(*) FILTER (WHERE level='SUBINSTALLATION') + INTO source_installations,source_subinstallations + FROM inventory_families + WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%'; + IF source_installations <> 14 OR source_subinstallations <> 109 THEN + RAISE EXCEPTION 'F5 family preload mismatch: installations %, subinstallations %', source_installations,source_subinstallations; + END IF; + + SELECT COUNT(*) INTO source_findings + FROM finding_catalog_items item + JOIN finding_categories category ON category.id=item.category_id + WHERE lower(category.code)='f5model' AND item.is_active=true; + IF source_findings <> 177 THEN + RAISE EXCEPTION 'F5 finding preload mismatch: %', source_findings; + END IF; + END $$; + SQL + + # Prove the five F5 migrations are actually reversible on a clean state. + for _ in 1 2 3 4 5; do + docker compose --env-file .env.example --profile tools run --rm migrate npm run migration:revert + done + + docker compose --env-file .env.example exec -T db \ + psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' + DO $$ + DECLARE f5_migrations integer; instance_column integer; + BEGIN + SELECT COUNT(*) INTO f5_migrations + FROM typeorm_migrations + WHERE name LIKE 'F5%1790087%'; + IF f5_migrations <> 0 THEN + RAISE EXCEPTION 'F5 rollback left % migration rows behind', f5_migrations; + END IF; + SELECT COUNT(*) INTO instance_column + FROM information_schema.columns + WHERE table_schema='public' AND table_name='assets' AND column_name='is_inventory_instance'; + IF instance_column <> 0 THEN + RAISE EXCEPTION 'F5 rollback left is_inventory_instance behind'; + END IF; + END $$; + SQL + + # Reapply them once more. Each F5 migration performs its own source/cardinality checks. + docker compose --env-file .env.example --profile tools run --rm migrate + docker compose --env-file .env.example exec -T db \ + psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 -Atc \ + "SELECT CASE WHEN COUNT(*)=5 THEN 'F5_REAPPLY_OK' ELSE 'F5_REAPPLY_FAILED:'||COUNT(*) END FROM typeorm_migrations WHERE name IN ('F5InventoryPhysicalInstance1790087100000','F5CanonicalInventoryHierarchy1790087150000','F5AuthoritativeTerritory1790087200000','F5OperationalContextCompatibility1790087250000','F5AuthoritativeInventoryCatalog1790087300000');" \ + | grep -Fx 'F5_REAPPLY_OK' - name: VPS-equivalent isolated API preflight run: | set -Eeuo pipefail diff --git a/api-v3/package.json b/api-v3/package.json index 674c7ff..269edaa 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.25.0-1", + "version": "0.26.0-1", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/asset-master/asset-master.module.ts b/api-v3/src/asset-master/asset-master.module.ts index f4484e3..44982eb 100644 --- a/api-v3/src/asset-master/asset-master.module.ts +++ b/api-v3/src/asset-master/asset-master.module.ts @@ -29,8 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller'; import { InventoryMergeService } from './inventory-merge.service'; import { MergedInventoryDossierService } from './merged-inventory-dossier.service'; -import { InventoryFunctionController } from './inventory-function.controller'; -import { InventoryFunctionService } from './inventory-function.service'; +import { InventoryBrowserController } from './inventory-browser.controller'; +import { InventoryBrowserService } from './inventory-browser.service'; @Module({ imports: [AuditModule], @@ -39,7 +39,7 @@ import { InventoryFunctionService } from './inventory-function.service'; AssetsController, InventoryStructureController, InventoryFamilyCatalogController, - InventoryFunctionController, + InventoryBrowserController, InventoryMergeController, FieldInventoryMergeController, AssetGeometriesController, @@ -56,7 +56,7 @@ import { InventoryFunctionService } from './inventory-function.service'; AssetsService, InventoryStructureService, InventoryFamilyCatalogService, - InventoryFunctionService, + InventoryBrowserService, InventoryMergeService, MergedInventoryDossierService, AssetGeometriesService, @@ -71,7 +71,6 @@ import { InventoryFunctionService } from './inventory-function.service'; exports: [ AssetHistoryService, AssetsService, - InventoryFunctionService, InventoryMergeService, MergedInventoryDossierService, AssetGeometriesService, diff --git a/api-v3/src/asset-master/asset-operational-relations.service.ts b/api-v3/src/asset-master/asset-operational-relations.service.ts index a42909a..09670c0 100644 --- a/api-v3/src/asset-master/asset-operational-relations.service.ts +++ b/api-v3/src/asset-master/asset-operational-relations.service.ts @@ -79,23 +79,15 @@ export class AssetOperationalRelationsService { async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> { await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA); const data = (await this.dataSource.query(` - SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName" - FROM ( - SELECT relation.company_id - FROM area_company_relations relation - WHERE relation.area_id = $1 - AND relation.relation_role = 'OPERATOR' - AND relation.valid_until IS NULL - UNION - SELECT asset.operator_company_id AS company_id - FROM assets asset - WHERE asset.operational_area_id = $1 - AND asset.operator_company_id IS NOT NULL - AND asset.information_status <> 'INACTIVE' - ) linked - INNER JOIN assets company ON company.id = linked.company_id + SELECT DISTINCT company.id, company.code, company.name, + company.common_name AS "commonName", company_type.name AS "typeName" + FROM area_company_relations relation + INNER JOIN assets company ON company.id = relation.company_id INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id - WHERE company.information_status <> 'INACTIVE' + WHERE relation.area_id = $1 + AND relation.relation_role = 'OPERATOR' + AND relation.valid_until IS NULL + AND company.information_status <> 'INACTIVE' ORDER BY company.name, company.code `, [areaId])) as OperationalAssetSummary[]; return { data }; @@ -104,23 +96,15 @@ export class AssetOperationalRelationsService { async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> { await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY); const data = (await this.dataSource.query(` - SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName" - FROM ( - SELECT relation.area_id - FROM area_company_relations relation - WHERE relation.company_id = $1 - AND relation.relation_role = 'OPERATOR' - AND relation.valid_until IS NULL - UNION - SELECT asset.operational_area_id AS area_id - FROM assets asset - WHERE asset.operator_company_id = $1 - AND asset.operational_area_id IS NOT NULL - AND asset.information_status <> 'INACTIVE' - ) linked - INNER JOIN assets area ON area.id = linked.area_id + SELECT DISTINCT area.id, area.code, area.name, + area.common_name AS "commonName", area_type.name AS "typeName" + FROM area_company_relations relation + INNER JOIN assets area ON area.id = relation.area_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id - WHERE area.information_status <> 'INACTIVE' + WHERE relation.company_id = $1 + AND relation.relation_role = 'OPERATOR' + AND relation.valid_until IS NULL + AND area.information_status <> 'INACTIVE' ORDER BY area.name, area.code `, [companyId])) as OperationalAssetSummary[]; return { data }; @@ -157,12 +141,49 @@ export class AssetOperationalRelationsService { const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]); if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' }); } + + if (dto.relationRole === AreaOrganizationRole.OPERATOR) { + const [currentOperator] = (await manager.query(` + SELECT relation.id,company.name AS "companyName" + FROM area_company_relations relation + JOIN assets company ON company.id=relation.company_id + WHERE relation.area_id=$1 + AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL + AND relation.company_id<>$2 + ORDER BY relation.valid_from DESC + LIMIT 1 + FOR UPDATE OF relation + `,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>; + if (currentOperator) { + throw new ConflictException({ + code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST', + message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`, + }); + } + } + const [row] = (await manager.query(` INSERT INTO area_company_relations ( area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; + + // Compatibility snapshot only. This does not move or re-parent Inventory. + // area_company_relations remains the temporal source of truth. + if (dto.relationRole === AreaOrganizationRole.OPERATOR) { + await manager.query(` + UPDATE assets asset + SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid + FROM asset_types type + WHERE type.id=asset.asset_type_id + AND type.operational_role='GENERIC' + AND asset.operational_area_id=$1::uuid + AND asset.operator_company_id IS DISTINCT FROM $2::uuid + `,[dto.areaId,dto.companyId,principal.userId]); + } + const created = await this.loadRelation(manager, row.id); await this.audit.record({ ...administrationAuditContext(principal, request), @@ -170,6 +191,9 @@ export class AssetOperationalRelationsService { entityType: 'area_company_relation', entityId: row.id, afterData: this.auditView(created), + metadata: dto.relationRole === AreaOrganizationRole.OPERATOR + ? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true } + : undefined, }, manager); return created; }); @@ -198,12 +222,9 @@ export class AssetOperationalRelationsService { message: 'La relación ya se encuentra finalizada', }); } - if (before.assignedAssetCount > 0) { - throw new ConflictException({ - code: 'AREA_COMPANY_RELATION_IN_USE', - message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`, - }); - } + + // F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company. + // Ending an operator relation must never be blocked by existing Inventory. await manager.query(` UPDATE area_company_relations SET valid_until = CURRENT_TIMESTAMP, @@ -220,6 +241,10 @@ export class AssetOperationalRelationsService { entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), + metadata: { + inventoryHierarchyChanged:false, + retainedCompatibilitySnapshotCount: before.assignedAssetCount, + }, }, manager); return updated; }); @@ -330,7 +355,8 @@ export class AssetOperationalRelationsService { (relation.valid_until IS NULL) AS active, CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset WHERE asset.operational_area_id = relation.area_id - AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount" + AND asset.operator_company_id = relation.company_id + AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount" FROM area_company_relations relation INNER JOIN assets area ON area.id = relation.area_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id diff --git a/api-v3/src/asset-master/dto/create-inventory-structure.dto.ts b/api-v3/src/asset-master/dto/create-inventory-structure.dto.ts index 5d400e3..cf675a9 100644 --- a/api-v3/src/asset-master/dto/create-inventory-structure.dto.ts +++ b/api-v3/src/asset-master/dto/create-inventory-structure.dto.ts @@ -10,6 +10,7 @@ import { } from 'class-validator'; export const INVENTORY_STRUCTURE_KINDS = [ + 'EMPRESA', 'AREA', 'YACIMIENTO', 'INSTALACION', diff --git a/api-v3/src/asset-master/dto/inventory-browser-query.dto.ts b/api-v3/src/asset-master/dto/inventory-browser-query.dto.ts new file mode 100644 index 0000000..cc2a102 --- /dev/null +++ b/api-v3/src/asset-master/dto/inventory-browser-query.dto.ts @@ -0,0 +1,63 @@ +import { Transform, Type } from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities'; + +export class InventoryBrowserQueryDto { + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; + + @IsOptional() + @IsUUID('4') + typeId?: string; + + @IsOptional() + @IsEnum(AssetInformationStatus) + status?: AssetInformationStatus; + + @IsOptional() + @IsEnum(AssetOperationalStatus) + operationalStatus?: AssetOperationalStatus; + + @IsOptional() + @IsUUID('4') + operationalAreaId?: string; + + @IsOptional() + @IsUUID('4') + operatorCompanyId?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) + @IsBoolean() + needsValidation?: boolean; + + @IsOptional() + @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) + @IsBoolean() + hasGeometry?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize = 25; +} diff --git a/api-v3/src/asset-master/dto/inventory-family-admin.dto.ts b/api-v3/src/asset-master/dto/inventory-family-admin.dto.ts new file mode 100644 index 0000000..a6227d2 --- /dev/null +++ b/api-v3/src/asset-master/dto/inventory-family-admin.dto.ts @@ -0,0 +1,71 @@ +import { Transform } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsBoolean, + IsIn, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateInventoryFamilyDto { + @IsIn(['INSTALLATION', 'SUBINSTALLATION']) + level!: 'INSTALLATION' | 'SUBINSTALLATION'; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(240) + name!: string; + + @IsOptional() + @IsUUID('4') + parentFamilyId?: string | null; + + @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @IsString({ each: true }) + @MaxLength(160, { each: true }) + informationLabels?: string[]; +} + +export class UpdateInventoryFamilyDto { + @IsOptional() + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(1) + @MaxLength(240) + name?: string; + + @IsOptional() + @IsUUID('4') + parentFamilyId?: string | null; + + @IsOptional() + @IsArray() + @ArrayMaxSize(100) + @IsString({ each: true }) + @MaxLength(160, { each: true }) + informationLabels?: string[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class ReplaceInventoryFamilyFindingsDto { + @IsArray() + @ArrayMaxSize(2000) + @IsUUID('4', { each: true }) + itemIds!: string[]; + + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) + @IsString() + @MinLength(5) + @MaxLength(2000) + reason!: string; +} diff --git a/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts b/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts index 21f1d06..cfcbb16 100644 --- a/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts +++ b/api-v3/src/asset-master/dto/list-asset-tree-query.dto.ts @@ -37,4 +37,9 @@ export class ListAssetTreeQueryDto { @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) @IsBoolean() hasGeometry?: boolean; + + @IsOptional() + @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) + @IsBoolean() + inventoryOnly?: boolean; } diff --git a/api-v3/src/asset-master/dto/list-assets-query.dto.ts b/api-v3/src/asset-master/dto/list-assets-query.dto.ts index 3c8fca4..4dee4e2 100644 --- a/api-v3/src/asset-master/dto/list-assets-query.dto.ts +++ b/api-v3/src/asset-master/dto/list-assets-query.dto.ts @@ -39,7 +39,6 @@ export class ListAssetsQueryDto { @IsEnum(AssetInformationStatus) status?: AssetInformationStatus; - @IsOptional() @IsEnum(AssetOperationalStatus) operationalStatus?: AssetOperationalStatus; @@ -54,6 +53,11 @@ export class ListAssetsQueryDto { @IsBoolean() hasGeometry?: boolean; + @IsOptional() + @Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value) + @IsBoolean() + inventoryOnly?: boolean; + @IsOptional() @IsUUID('4') parentId?: string; diff --git a/api-v3/src/asset-master/inventory-browser.controller.ts b/api-v3/src/asset-master/inventory-browser.controller.ts new file mode 100644 index 0000000..04f84e6 --- /dev/null +++ b/api-v3/src/asset-master/inventory-browser.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto'; +import { InventoryBrowserService } from './inventory-browser.service'; + +@Controller('inventory-browser') +@RequirePermissions('assets.read') +export class InventoryBrowserController { + constructor(private readonly inventoryBrowser: InventoryBrowserService) {} + + @Get('items') + items(@Query() query: InventoryBrowserQueryDto) { + return this.inventoryBrowser.items(query); + } + + @Get('areas') + areas(@Query() query: InventoryBrowserQueryDto) { + return this.inventoryBrowser.areas(query); + } + + @Get(':parentId/children') + children( + @Param('parentId', new ParseUUIDPipe({ version: '4' })) parentId: string, + @Query() query: InventoryBrowserQueryDto, + ) { + return this.inventoryBrowser.children(parentId, query); + } +} diff --git a/api-v3/src/asset-master/inventory-browser.service.ts b/api-v3/src/asset-master/inventory-browser.service.ts new file mode 100644 index 0000000..10e6d30 --- /dev/null +++ b/api-v3/src/asset-master/inventory-browser.service.ts @@ -0,0 +1,283 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import type { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto'; + +type ParentContext = { + id: string; + code: string; + name: string; + typeCode: string; +}; + +@Injectable() +export class InventoryBrowserService { + constructor(private readonly dataSource: DataSource) {} + + async items(query: InventoryBrowserQueryDto) { + const params: unknown[] = []; + const conditions = [ + 'asset.is_inventory_instance=true', + "asset.information_status<>'INACTIVE'", + 'type.is_active=true', + ]; + const add = (value: unknown): string => { + params.push(value); + return `$${params.length}`; + }; + + if (query.search?.trim()) { + const p = add(`%${query.search.trim()}%`); + conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`); + } + if (query.typeId) conditions.push(`asset.asset_type_id=${add(query.typeId)}::uuid`); + if (query.status) conditions.push(`asset.information_status=${add(query.status)}::asset_information_status`); + if (query.operationalStatus) conditions.push(`asset.operational_status=${add(query.operationalStatus)}::asset_operational_status`); + if (query.needsValidation === true) conditions.push("asset.information_status NOT IN ('VALIDATED','INACTIVE')"); + if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'"); + if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); + if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); + if (query.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::uuid`); + if (query.operatorCompanyId) { + const company = add(query.operatorCompanyId); + conditions.push(`EXISTS ( + SELECT 1 FROM area_company_relations relation + WHERE relation.area_id=asset.operational_area_id + AND relation.company_id=${company}::uuid + AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL + )`); + } + + const where = conditions.join(' AND '); + const [countRow] = (await this.dataSource.query(` + SELECT COUNT(*)::integer AS total + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE ${where} + `,params)) as Array<{total:number}>; + const total=Number(countRow?.total ?? 0); + const offset=(query.page-1)*query.pageSize; + params.push(query.pageSize); + const limit=`$${params.length}`; + params.push(offset); + const offsetParam=`$${params.length}`; + + const data=await this.dataSource.query(` + SELECT + asset.id,asset.code,asset.name,asset.common_name AS "commonName", + JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, + CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',parent.id,'code',parent.code,'name',parent.name + ) END AS parent, + CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',area.id,'code',area.code,'name',area.name + ) END AS "operationalArea", + ( + SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) + FROM area_company_relations relation + JOIN assets company ON company.id=relation.company_id + WHERE relation.area_id=asset.operational_area_id + AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL + ORDER BY relation.valid_from DESC,relation.created_at DESC + LIMIT 1 + ) AS "operatorCompany", + asset.information_status AS "informationStatus", + asset.operational_status AS "operationalStatus", + 0::integer AS "childrenCount", + EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry", + CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType", + (SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount", + asset.data_origin AS "dataOrigin", + (asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified", + asset.current_version AS "currentVersion", + asset.updated_at AS "updatedAt" + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + LEFT JOIN assets parent ON parent.id=asset.parent_id + LEFT JOIN assets area ON area.id=asset.operational_area_id + LEFT JOIN LATERAL ( + SELECT ST_GeometryType(geometry.geometry)::text AS type + FROM asset_geometries geometry + WHERE geometry.asset_id=asset.id + ORDER BY geometry.updated_at DESC + LIMIT 1 + ) geometry_type ON true + WHERE ${where} + ORDER BY asset.name,asset.code + LIMIT ${limit} OFFSET ${offsetParam} + `,params); + + return { + data, + meta:{ + page:query.page, + pageSize:query.pageSize, + total, + totalPages:total===0 ? 0 : Math.ceil(total/query.pageSize), + }, + }; + } + + async areas(query: InventoryBrowserQueryDto) { + const params: unknown[] = []; + const conditions = [ + "type.operational_role='AREA'", + "area.information_status<>'INACTIVE'", + 'type.is_active=true', + ]; + const add = (value: unknown): string => { + params.push(value); + return `$${params.length}`; + }; + + if (query.search?.trim()) { + const p = add(`%${query.search.trim()}%`); + conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`); + } + if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`); + if (query.operatorCompanyId) { + const p = add(query.operatorCompanyId); + conditions.push(`EXISTS ( + SELECT 1 FROM area_company_relations relation + WHERE relation.area_id=area.id + AND relation.company_id=${p}::uuid + AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL + )`); + } + + const data = await this.dataSource.query(` + SELECT + area.id,area.code,area.name,area.common_name AS "commonName", + JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, + area.information_status AS "informationStatus", + area.operational_status AS "operationalStatus", + ( + SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) + FROM area_company_relations relation + JOIN assets company ON company.id=relation.company_id + WHERE relation.area_id=area.id + AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL + ORDER BY relation.valid_from DESC,relation.created_at DESC + LIMIT 1 + ) AS "currentOperator", + ( + SELECT COUNT(*)::integer + FROM assets yacimiento + JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id + WHERE yacimiento.parent_id=area.id + AND lower(ytype.code)='yacimiento' + AND yacimiento.information_status<>'INACTIVE' + ) AS "yacimientoCount", + ( + SELECT COUNT(*)::integer + FROM assets inventory + WHERE inventory.is_inventory_instance=true + AND inventory.information_status<>'INACTIVE' + AND inventory.operational_area_id=area.id + ) AS "inventoryCount" + FROM assets area + JOIN asset_types type ON type.id=area.asset_type_id + WHERE ${conditions.join(' AND ')} + ORDER BY area.name,area.code + `, params); + + return { data, meta: { count: data.length } }; + } + + async children(parentId: string, query: InventoryBrowserQueryDto) { + const parent = await this.parent(parentId); + const allowedChildType = this.allowedChildType(parent.typeCode); + if (!allowedChildType) return { parent, data: [], meta: { count: 0, hasMore: false } }; + + const params: unknown[] = [parentId, allowedChildType]; + const conditions = [ + 'asset.parent_id=$1::uuid', + 'lower(type.code)=lower($2)', + "asset.information_status<>'INACTIVE'", + 'type.is_active=true', + ]; + if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') { + conditions.push('asset.is_inventory_instance=true'); + } + if (query.search?.trim()) { + params.push(`%${query.search.trim()}%`); + const p = `$${params.length}`; + conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`); + } + + params.push(201); + const limit = `$${params.length}`; + const rows = await this.dataSource.query(` + SELECT + asset.id,asset.code,asset.name,asset.common_name AS "commonName", + JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, + CASE WHEN parent_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',parent_asset.id,'code',parent_asset.code,'name',parent_asset.name + ) END AS parent, + asset.information_status AS "informationStatus", + asset.operational_status AS "operationalStatus", + asset.is_inventory_instance AS "isInventoryInstance", + CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',family.id,'code',family.code,'name',family.name,'level',family.level + ) END AS "inventoryFamily", + ( + SELECT COUNT(*)::integer + FROM assets child + WHERE child.parent_id=asset.id + AND child.information_status<>'INACTIVE' + AND ( + lower(type.code)='area' + OR child.is_inventory_instance=true + OR EXISTS ( + SELECT 1 FROM asset_types child_type + WHERE child_type.id=child.asset_type_id AND lower(child_type.code)='yacimiento' + ) + ) + ) AS "childrenCount", + EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry", + asset.updated_at AS "updatedAt" + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + LEFT JOIN assets parent_asset ON parent_asset.id=asset.parent_id + LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id + WHERE ${conditions.join(' AND ')} + ORDER BY asset.name,asset.code + LIMIT ${limit} + `, params); + + const hasMore = rows.length > 200; + const data = hasMore ? rows.slice(0,200) : rows; + return { parent, data, meta: { count: data.length, hasMore } }; + } + + private async parent(parentId: string): Promise { + const [parent] = await this.dataSource.query(` + SELECT asset.id,asset.code,asset.name,type.code AS "typeCode" + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE' + `,[parentId]) as ParentContext[]; + if (!parent) { + throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' }); + } + if (!['area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) { + throw new BadRequestException({ + code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID', + message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación', + }); + } + return parent; + } + + private allowedChildType(typeCode: string): string | null { + switch (typeCode.toLowerCase()) { + case 'area': return 'yacimiento'; + case 'yacimiento': return 'instalacion'; + case 'instalacion': return 'subinstalacion'; + default: return null; + } + } +} diff --git a/api-v3/src/asset-master/inventory-family-catalog.controller.ts b/api-v3/src/asset-master/inventory-family-catalog.controller.ts index 00a0a17..88708c2 100644 --- a/api-v3/src/asset-master/inventory-family-catalog.controller.ts +++ b/api-v3/src/asset-master/inventory-family-catalog.controller.ts @@ -1,11 +1,66 @@ -import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Req, +} from '@nestjs/common'; +import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + CreateInventoryFamilyDto, + ReplaceInventoryFamilyFindingsDto, + UpdateInventoryFamilyDto, +} from './dto/inventory-family-admin.dto'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; @Controller('inventory-families') export class InventoryFamilyCatalogController { constructor(private readonly families: InventoryFamilyCatalogService) {} + @Get('admin') + @RequirePermissions('asset_types.read') + admin() { + return this.families.listAdmin(); + } + + @Post() + @RequirePermissions('asset_types.manage') + create( + @Body() dto: CreateInventoryFamilyDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.families.create(dto,principal,request); + } + + @Patch(':familyId') + @RequirePermissions('asset_types.manage') + update( + @Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string, + @Body() dto:UpdateInventoryFamilyDto, + @CurrentAuth() principal:AuthPrincipal, + @Req() request:RequestWithContext, + ) { + return this.families.update(familyId,dto,principal,request); + } + + @Put(':familyId/findings') + @RequirePermissions('finding_catalog.manage') + replaceFindings( + @Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string, + @Body() dto:ReplaceInventoryFamilyFindingsDto, + @CurrentAuth() principal:AuthPrincipal, + @Req() request:RequestWithContext, + ) { + return this.families.replaceFindings(familyId,dto,principal,request); + } + @Get(':familyId/findings') @RequirePermissions('assets.read') findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) { diff --git a/api-v3/src/asset-master/inventory-family-catalog.service.ts b/api-v3/src/asset-master/inventory-family-catalog.service.ts index 0c76928..c6d7c93 100644 --- a/api-v3/src/asset-master/inventory-family-catalog.service.ts +++ b/api-v3/src/asset-master/inventory-family-catalog.service.ts @@ -1,28 +1,71 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { randomUUID } from 'node:crypto'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { AuditAction } from '../database/entities'; +import type { + CreateInventoryFamilyDto, + ReplaceInventoryFamilyFindingsDto, + UpdateInventoryFamilyDto, +} from './dto/inventory-family-admin.dto'; + +type FamilyRow = { + id: string; + code: string; + name: string; + level: 'INSTALLATION' | 'SUBINSTALLATION'; + informationLabels: string[]; + sourceReference: string | null; + isActive: boolean; + parentFamilyId: string | null; + parentFamilyCode: string | null; + parentFamilyName: string | null; + assetCount: number; + findingCount: number; + findingItemIds: string[]; +}; @Injectable() export class InventoryFamilyCatalogService { - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + ) {} + + async listAdmin() { + const data = await this.dataSource.query(` + SELECT + family.id,family.code,family.name,family.level, + family.information_labels AS "informationLabels", + family.source_reference AS "sourceReference", + family.is_active AS "isActive", + parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName", + (SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount", + (SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount", + COALESCE(( + SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id) + FROM finding_catalog_item_inventory_families mapping + JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id + WHERE mapping.inventory_family_id=family.id + ),'[]'::jsonb) AS "findingItemIds" + FROM inventory_families family + LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id + LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id + ORDER BY family.level,family.is_active DESC, + COALESCE(parent.name,''),family.name,family.code + `) as FamilyRow[]; + return { data }; + } async findings(familyId: string) { - const [family] = await this.dataSource.query(` - SELECT id,code,name,level,information_labels AS "informationLabels" - FROM inventory_families - WHERE id=$1::uuid AND is_active=true - `, [familyId]) as Array<{ - id: string; - code: string; - name: string; - level: string; - informationLabels: string[]; - }>; - if (!family) { - throw new NotFoundException({ - code: 'INVENTORY_FAMILY_NOT_FOUND', - message: 'La familia técnica no existe', - }); - } + const family = await this.family(familyId, false); const items = await this.dataSource.query(` SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title, item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity", @@ -36,4 +79,221 @@ export class InventoryFamilyCatalogService { `, [familyId]); return { family, items, count: items.length }; } + + async create( + dto: CreateInventoryFamilyDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null); + const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`; + const [inserted] = (await manager.query(` + INSERT INTO inventory_families( + code,name,level,legacy_type_code,information_labels,source_reference,is_active + ) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true) + RETURNING id + `,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>; + if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario'); + if (parentId) { + await manager.query(` + INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) + VALUES ($1::uuid,$2::uuid) + `,[inserted.id,parentId]); + } + const created = await this.family(inserted.id,false,manager); + await this.audit.record({ + ...administrationAuditContext(principal,request), + action: AuditAction.ASSET_UPDATED, + entityType: 'inventory_family', + entityId: inserted.id, + afterData: created as unknown as Record, + metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' }, + },manager); + return created; + }); + } + + async update( + familyId: string, + dto: UpdateInventoryFamilyDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const before = await this.family(familyId,false,manager,true); + const nextParentId = dto.parentFamilyId === undefined + ? before.parentFamilyId + : dto.parentFamilyId; + const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId); + await manager.query(` + UPDATE inventory_families SET + name=COALESCE($2::varchar,name), + information_labels=COALESCE($3::jsonb,information_labels), + is_active=COALESCE($4::boolean,is_active), + updated_at=CURRENT_TIMESTAMP + WHERE id=$1::uuid + `,[ + familyId, + dto.name ?? null, + dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)), + dto.isActive ?? null, + ]); + if (before.level==='SUBINSTALLATION') { + await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]); + if (parentId) { + await manager.query(` + INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) + VALUES ($1::uuid,$2::uuid) + `,[familyId,parentId]); + } + } + const after = await this.family(familyId,false,manager); + await this.audit.record({ + ...administrationAuditContext(principal,request), + action: AuditAction.ASSET_UPDATED, + entityType: 'inventory_family', + entityId: familyId, + beforeData: before as unknown as Record, + afterData: after as unknown as Record, + metadata: { operation:'INVENTORY_FAMILY_UPDATED' }, + },manager); + return after; + }); + } + + async replaceFindings( + familyId: string, + dto: ReplaceInventoryFamilyFindingsDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + return this.dataSource.transaction(async (manager) => { + const family = await this.family(familyId,false,manager,true); + const uniqueIds=[...new Set(dto.itemIds)]; + if (uniqueIds.length) { + const [count] = (await manager.query(` + SELECT COUNT(*)::integer AS total + FROM finding_catalog_items item + JOIN finding_categories category ON category.id=item.category_id + WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true + `,[uniqueIds])) as Array<{total:number}>; + if (Number(count?.total ?? 0)!==uniqueIds.length) { + throw new BadRequestException({ + code:'INVENTORY_FAMILY_FINDING_INVALID', + message:'Uno o más Hallazgos elegidos no están activos en el catálogo', + }); + } + } + const beforeIds=family.findingItemIds; + await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]); + if (uniqueIds.length) { + await manager.query(` + INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) + SELECT item_id,$2::uuid FROM UNNEST($1::uuid[]) AS selected(item_id) + ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING + `,[uniqueIds,familyId]); + } + const after=await this.family(familyId,false,manager); + await this.audit.record({ + ...administrationAuditContext(principal,request), + action: AuditAction.ASSET_UPDATED, + entityType:'inventory_family_findings', + entityId:familyId, + beforeData:{ itemIds:beforeIds }, + afterData:{ itemIds:after.findingItemIds }, + metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason }, + },manager); + return this.findingsWithManager(manager,familyId); + }); + } + + private async findingsWithManager(manager:EntityManager,familyId:string) { + const family=await this.family(familyId,false,manager); + const items=await manager.query(` + SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title, + item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity", + category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName" + FROM finding_catalog_item_inventory_families mapping + JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id + JOIN finding_categories category ON category.id=item.category_id + WHERE mapping.inventory_family_id=$1::uuid + AND item.is_active=true AND category.is_active=true + ORDER BY category.sort_order,item.source_number,item.title + `,[familyId]); + return {family,items,count:items.length}; + } + + private async family( + familyId:string, + activeOnly:boolean, + manager:EntityManager=this.dataSource.manager, + lock=false, + ):Promise { + const rows=(await manager.query(` + SELECT family.id,family.code,family.name,family.level, + family.information_labels AS "informationLabels", + family.source_reference AS "sourceReference",family.is_active AS "isActive", + parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName", + (SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount", + (SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount", + COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id) + FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds" + FROM inventory_families family + LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id + LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id + WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''} + ${lock ? 'FOR UPDATE OF family' : ''} + `,[familyId])) as FamilyRow[]; + if (!rows[0]) throw new NotFoundException({ + code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe', + }); + return rows[0]; + } + + private async validateParent( + manager:EntityManager, + level:'INSTALLATION'|'SUBINSTALLATION', + parentFamilyId:string|null, + ownId:string|null, + ):Promise { + if (level==='INSTALLATION') { + if (parentFamilyId) throw new BadRequestException({ + code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED', + message:'Una clasificación de Instalación no tiene clasificación padre', + }); + return null; + } + if (!parentFamilyId) throw new BadRequestException({ + code:'INVENTORY_FAMILY_PARENT_REQUIRED', + message:'Una Subinstalación debe pertenecer a un tipo de Instalación', + }); + if (parentFamilyId===ownId) throw new BadRequestException({ + code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre', + }); + const rows=(await manager.query(` + SELECT id FROM inventory_families + WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true + LIMIT 1 + `,[parentFamilyId])) as Array<{id:string}>; + if (!rows[0]) throw new BadRequestException({ + code:'INVENTORY_FAMILY_PARENT_INVALID', + message:'La Subinstalación debe vincularse a una clasificación de Instalación activa', + }); + return parentFamilyId; + } + + private cleanLabels(labels:string[]):string[] { + const unique=new Map(); + for (const raw of labels) { + const clean=raw.trim(); + if (!clean) continue; + const identity=clean.toLocaleLowerCase('es-AR'); + if (!unique.has(identity)) unique.set(identity,clean); + } + if (unique.size>100) throw new ConflictException({ + code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información', + }); + return [...unique.values()]; + } } diff --git a/api-v3/src/asset-master/inventory-merge.service.ts b/api-v3/src/asset-master/inventory-merge.service.ts index afbe6ae..de47f7f 100644 --- a/api-v3/src/asset-master/inventory-merge.service.ts +++ b/api-v3/src/asset-master/inventory-merge.service.ts @@ -39,6 +39,22 @@ type MergeRow = { requestId: string | null; }; +type DocumentInvariantRow = { + actId: string; + actStatus: string; + lockedSha256: string | null; + closureSha256: string | null; + sealedAt: Date | null; + actVersion: number; + reportId: string | null; + reportStatus: string | null; + reportActClosureSha256: string | null; + reportFrozenSha256: string | null; + gedoPdfSha256: string | null; + wordSha256: string | null; + reportRevision: number | null; +}; + const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']); @Injectable() @@ -161,6 +177,19 @@ export class InventoryMergeService { if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); this.validatePair(source, canonical); + + // Empresa is temporal inspection context, not physical ownership. A merge is + // valid when both records resolve to the same Area in the physical hierarchy, + // even if their historical operator snapshots differ. + const [sourceAreaId, canonicalAreaId] = await Promise.all([ + this.resolvePhysicalAreaId(manager, source.id), + this.resolvePhysicalAreaId(manager, canonical.id), + ]); + if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({ + code: 'INVENTORY_MERGE_AREA_MISMATCH', + message: 'Los duplicados deben pertenecer a la misma Área física', + }); + const sourceParentCanonical = source.parentId ? await this.resolveCanonicalId(manager, source.parentId) : null; @@ -197,6 +226,8 @@ export class InventoryMergeService { }); } + const affectedAssetIds = [source.id, canonical.id]; + const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds); const [sourceSnapshot, canonicalSnapshot] = await Promise.all([ this.snapshot(manager, source.id), this.snapshot(manager, canonical.id), @@ -279,6 +310,17 @@ export class InventoryMergeService { request, ); + // No historical Acta/Finding/Informe foreign key is rewritten by a merge. + // Verify that legal/documentary fingerprints are byte-for-byte unchanged + // before committing the transaction; otherwise rollback the entire merge. + const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds); + if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) { + throw new ConflictException({ + code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN', + message: 'La fusión intentó alterar la huella documental histórica y fue revertida', + }); + } + const result = { merge: mergeRecord, source: { id: source.id, code: source.code, name: source.name }, @@ -286,6 +328,7 @@ export class InventoryMergeService { sourceVersionNumber, reparentedChildIds, historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED', + documentaryInvariantsVerified: true, }; await this.audit.record({ ...administrationAuditContext(principal, request), @@ -298,10 +341,12 @@ export class InventoryMergeService { operation: 'CHRONOLOGICAL_MERGE', sourceAssetId: source.id, canonicalAssetId: canonical.id, + physicalAreaId: sourceAreaId, reason: dto.reason, sourceVersionNumber, reparentedChildIds, historicalReferencesRewritten: false, + documentaryInvariantsVerified: true, }, }, manager); return result; @@ -322,14 +367,59 @@ export class InventoryMergeService { code: 'INVENTORY_MERGE_CANONICAL_INACTIVE', message: 'El registro canónico no puede estar inactivo', }); - if (!source.operationalAreaId || !source.operatorCompanyId - || source.operationalAreaId !== canonical.operationalAreaId - || source.operatorCompanyId !== canonical.operatorCompanyId) { - throw new BadRequestException({ - code: 'INVENTORY_MERGE_CONTEXT_MISMATCH', - message: 'Los registros deben pertenecer a la misma Área y Operadora', - }); - } + } + + private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise { + const rows = (await manager.query(` + WITH RECURSIVE lineage AS ( + SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth + FROM assets asset WHERE asset.id=$1::uuid + UNION ALL + SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1 + FROM assets parent + JOIN lineage ON lineage.parent_id=parent.id + WHERE lineage.depth<32 + ) + SELECT lineage.id + FROM lineage + JOIN asset_types type ON type.id=lineage.asset_type_id + WHERE type.operational_role='AREA' + ORDER BY lineage.depth + LIMIT 1 + `, [assetId])) as Array<{ id: string }>; + return rows[0]?.id ?? null; + } + + private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise { + return (await manager.query(` + WITH affected_acts AS ( + SELECT DISTINCT act.id + FROM inspection_acts act + LEFT JOIN inspection_act_assets act_asset + ON act_asset.act_id=act.id AND act_asset.included=true + LEFT JOIN inspection_findings finding ON finding.act_id=act.id + WHERE act_asset.asset_id=ANY($1::uuid[]) + OR finding.asset_id=ANY($1::uuid[]) + ) + SELECT + act.id AS "actId", + act.status AS "actStatus", + act.locked_sha256 AS "lockedSha256", + act.closure_sha256 AS "closureSha256", + act.sealed_at AS "sealedAt", + act.current_version AS "actVersion", + report.id AS "reportId", + report.status AS "reportStatus", + report.act_closure_sha256 AS "reportActClosureSha256", + report.frozen_sha256 AS "reportFrozenSha256", + report.gedo_pdf_sha256 AS "gedoPdfSha256", + report.word_sha256 AS "wordSha256", + report.current_revision_number AS "reportRevision" + FROM affected_acts affected + JOIN inspection_acts act ON act.id=affected.id + LEFT JOIN inspection_reports report ON report.act_id=act.id + ORDER BY act.id,report.id NULLS FIRST + `, [assetIds])) as DocumentInvariantRow[]; } private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise { diff --git a/api-v3/src/asset-master/inventory-structure.service.ts b/api-v3/src/asset-master/inventory-structure.service.ts index dbf0121..196f427 100644 --- a/api-v3/src/asset-master/inventory-structure.service.ts +++ b/api-v3/src/asset-master/inventory-structure.service.ts @@ -36,18 +36,17 @@ type ParentRow = { code: string; name: string; typeCode: string; - operationalAreaId: string | null; - operatorCompanyId: string | null; inventoryFamilyId: string | null; }; -const TYPE_CODE_BY_KIND: Record = { +const TYPE_CODE_BY_KIND: Record, string> = { AREA: 'area', YACIMIENTO: 'yacimiento', INSTALACION: 'instalacion', SUBINSTALACION: 'subinstalacion', }; const PARENT_TYPE_BY_KIND: Record = { + EMPRESA: null, AREA: null, YACIMIENTO: 'area', INSTALACION: 'yacimiento', @@ -70,18 +69,29 @@ export class InventoryStructureService { const types = (await this.dataSource.query(` SELECT id,code,name FROM asset_types - WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion') - AND is_active=true - ORDER BY CASE lower(code) - WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2 - WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END + WHERE ( + lower(code) IN ('area','yacimiento','instalacion','subinstalacion') + OR operational_role='COMPANY' + ) AND is_active=true + ORDER BY CASE + WHEN operational_role='COMPANY' THEN 0 + WHEN lower(code)='area' THEN 1 + WHEN lower(code)='yacimiento' THEN 2 + WHEN lower(code)='instalacion' THEN 3 + WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END `)) as StructureTypeRow[]; - if (types.length !== 4) { + const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase())); + const area = types.find((item) => item.code.toLowerCase()==='area'); + const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento'); + const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion'); + const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion'); + if (!company || !area || !yacimiento || !instalacion || !subinstalacion) { throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE', - message: 'La estructura del Inventario todavía no está completamente configurada', + message: 'La configuración maestra de Empresa e Inventario todavía no está completa', }); } + const families = (await this.dataSource.query(` SELECT family.id,family.code,family.name,family.level, family.legacy_type_code AS "legacyTypeCode", @@ -93,12 +103,16 @@ export class InventoryStructureService { WHERE family.is_active=true ORDER BY family.level,family.name,family.code `)) as FamilyRow[]; + return { + independentMasters: [ + { kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false }, + ], levels: [ - { kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false }, - { kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false }, - { kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true }, - { kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true }, + { kind: '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 }, ], installationFamilies: families.filter((item) => item.level==='INSTALLATION'), subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'), @@ -107,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 === 'AREA') { + 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', @@ -154,9 +168,8 @@ export class InventoryStructureService { const type = await this.requireStructureType(manager, dto.kind); const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null); const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent); - const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); - const operationalAreaId = parent?.operationalAreaId ?? null; - const operatorCompanyId = parent?.operatorCompanyId ?? null; + const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); + const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null; const inserted = (await manager.query(` INSERT INTO assets ( @@ -164,30 +177,37 @@ export class InventoryStructureService { code,name,common_name,description,information_status,operational_status, data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by ) VALUES ( - $1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid, - $6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status, - $12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid + $1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid, + $5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status, + $11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid ) RETURNING id `, [ type.id, parent?.id ?? null, operationalAreaId, - operatorCompanyId, family?.id ?? null, - code, + generatedCode, dto.name, dto.commonName ?? null, dto.description ?? null, AssetInformationStatus.DRAFT, AssetOperationalStatus.UNKNOWN, AssetDataOrigin.MANUAL, - 'Inventario estructural F3.1', - `inventory-structure:${dto.kind.toLowerCase()}`, + dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5', + dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`, family ? `Familia técnica: ${family.code} · ${family.name}` : null, principal.userId, ])) as Array<{ id: string }>; const id = inserted[0]?.id; - if (!id) throw new Error('No se pudo crear el registro estructural'); + if (!id) throw new Error('No se pudo crear el registro'); + + if (dto.kind === 'EMPRESA') { + await manager.query(` + INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by) + VALUES ($1::uuid,'COMPANY',$2,$3::uuid) + ON CONFLICT (asset_id) DO UPDATE SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP + `,[id,dto.name,principal.userId]); + } const versionNumber = await this.history.capture( manager, @@ -200,13 +220,12 @@ export class InventoryStructureService { INSERT INTO asset_context_history ( asset_id,parent_id,operational_area_id,operator_company_id,valid_from, change_reason,asset_version_number,source,request_id,created_by - ) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8) + ) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7) `, [ id, parent?.id ?? null, operationalAreaId, - operatorCompanyId, - 'Alta guiada de Inventario estructural F3.1', + dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5', versionNumber, request.requestId, principal.userId, @@ -223,6 +242,7 @@ export class InventoryStructureService { inventoryStructureKind: dto.kind, inventoryFamilyId: family?.id ?? null, inventoryFamilyCode: family?.code ?? null, + operatorOwnership: false, }, }, manager); return created; @@ -231,7 +251,7 @@ export class InventoryStructureService { if (isUniqueViolation(error)) { throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS', - message: 'Ya existe un registro de Inventario con ese código', + message: 'Ya existe un registro con ese código', }); } throw error; @@ -239,10 +259,11 @@ export class InventoryStructureService { } private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise { - const rows = (await manager.query(` - SELECT id,code,name FROM asset_types - WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1 - `, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[]; + const sql = kind === 'EMPRESA' + ? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1` + : `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`; + const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude]]; + const rows = (await manager.query(sql,params)) as StructureTypeRow[]; if (!rows[0]) { throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', @@ -261,8 +282,10 @@ export class InventoryStructureService { if (!expectedType) { if (parentId) { throw new BadRequestException({ - code: 'INVENTORY_AREA_MUST_BE_ROOT', - message: 'Un Área se crea como registro raíz y no puede tener padre', + code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', + message: kind === 'EMPRESA' + ? 'Una Empresa es un maestro independiente y no puede tener padre' + : 'Un Área es un registro raíz y no puede tener padre', }); } return null; @@ -275,8 +298,6 @@ export class InventoryStructureService { } const rows = (await manager.query(` SELECT asset.id,asset.code,asset.name,type.code AS "typeCode", - asset.operational_area_id AS "operationalAreaId", - asset.operator_company_id AS "operatorCompanyId", asset.inventory_family_id AS "inventoryFamilyId" FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id @@ -294,6 +315,30 @@ export class InventoryStructureService { return parent; } + private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise { + if (parent.typeCode.toLowerCase()==='area') return parent.id; + const rows = (await manager.query(` + WITH RECURSIVE lineage AS ( + SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid + UNION ALL + SELECT parent.id,parent.parent_id,parent.asset_type_id + FROM assets parent JOIN lineage child ON child.parent_id=parent.id + ) + SELECT lineage.id + FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id + WHERE type.operational_role='AREA' + LIMIT 1 + `,[parent.id])) as IdRow[]; + const areaId=rows[0]?.id; + if (!areaId) { + throw new ConflictException({ + code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING', + message:'La ubicación seleccionada no pertenece a un Área válida', + }); + } + return areaId; + } + private async requireFamily( manager: EntityManager, kind: InventoryStructureKind, @@ -304,7 +349,7 @@ export class InventoryStructureService { if (!expectedLevel) { if (familyId) throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', - message: 'Área y Yacimiento no llevan familia técnica', + message: 'Empresa, Área y Yacimiento no llevan familia técnica', }); return null; } @@ -339,7 +384,7 @@ export class InventoryStructureService { } private generatedCode(kind: InventoryStructureKind, name: string): string { - const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; + const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; const readable = name .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') @@ -356,6 +401,7 @@ export class InventoryStructureService { asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus", JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, + CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id',family.id,'code',family.code,'name',family.name,'level',family.level, 'informationLabels',family.information_labels @@ -364,9 +410,12 @@ export class InventoryStructureService { FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN assets parent ON parent.id=asset.parent_id + LEFT JOIN assets area ON area.id=asset.operational_area_id LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id WHERE asset.id=$1::uuid `, [id]); return rows[0]; } } + +type IdRow = { id: string }; diff --git a/api-v3/src/dashboard/dashboard.service.ts b/api-v3/src/dashboard/dashboard.service.ts index 56e5daa..d77ce32 100644 --- a/api-v3/src/dashboard/dashboard.service.ts +++ b/api-v3/src/dashboard/dashboard.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -export interface DashboardAuditItem { +export interface DashboardInspectorActivityItem { id: string; occurredAt: Date; actorUsername: string | null; @@ -19,6 +19,7 @@ interface DashboardCountsRow { inactiveUsers: number | string; activeSessions: number | string; openFindings: number | string; + actsInFollowUp: number | string; findingsWithoutControlDate: number | string; overdueControls: number | string; controlsNext30Days: number | string; @@ -49,9 +50,14 @@ export class DashboardService { return this.dataSource.transaction('REPEATABLE READ', async (manager) => { const [counts] = (await manager.query(` SELECT - (SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets", - (SELECT COUNT(*) FROM assets WHERE information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation", - (SELECT COUNT(*) FROM assets asset WHERE asset.information_status <> 'INACTIVE' AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)) AS "assetsWithoutGeometry", + (SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status <> 'INACTIVE') AS "totalAssets", + (SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation", + ( + SELECT COUNT(*) FROM assets asset + WHERE asset.is_inventory_instance=true + AND asset.information_status <> 'INACTIVE' + AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) + ) AS "assetsWithoutGeometry", (SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections", (SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers", (SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers", @@ -61,9 +67,22 @@ export class DashboardService { WHERE revoked_at IS NULL AND expires_at > CURRENT_TIMESTAMP ) AS "activeSessions", + (SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN') AS "openFindings", ( - SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN' - ) AS "openFindings", + SELECT COUNT(*) + FROM inspection_acts act + WHERE act.status IN ('SEALED','CLOSED','RECTIFIED') + AND NOT ( + EXISTS ( + SELECT 1 FROM inspection_findings finding + WHERE finding.act_id=act.id AND finding.status<>'VOIDED' + ) + AND NOT EXISTS ( + SELECT 1 FROM inspection_findings finding + WHERE finding.act_id=act.id AND finding.status='OPEN' + ) + ) + ) AS "actsInFollowUp", ( SELECT COUNT(*) FROM inspection_findings finding @@ -115,7 +134,7 @@ export class DashboardService { ) AS "sealedActsWithoutReport" `)) as DashboardCountsRow[]; - const recentAudit = (await manager.query(` + const recentInspectorActivity = (await manager.query(` SELECT event.id, event.occurred_at AS "occurredAt", @@ -124,9 +143,25 @@ export class DashboardService { event.entity_type AS "entityType", event.entity_id AS "entityId" FROM audit_events event + WHERE event.actor_user_id IS NOT NULL + AND ( + EXISTS ( + SELECT 1 FROM inspection_visit_members member + WHERE member.user_id=event.actor_user_id AND member.included=true + ) + OR EXISTS ( + SELECT 1 FROM inspection_visits visit + WHERE visit.lead_inspector_user_id=event.actor_user_id + ) + ) + AND ( + event.action LIKE 'INSPECTION_%' + OR event.action LIKE 'ASSET_%' + OR event.action LIKE 'FINDING_%' + ) ORDER BY event.occurred_at DESC, event.id DESC - LIMIT 6 - `)) as DashboardAuditItem[]; + LIMIT 8 + `)) as DashboardInspectorActivityItem[]; const upcomingControls = (await manager.query(` SELECT @@ -168,6 +203,7 @@ export class DashboardService { inactiveUsers: Number(counts?.inactiveUsers ?? 0), activeSessions: Number(counts?.activeSessions ?? 0), openFindings: Number(counts?.openFindings ?? 0), + actsInFollowUp: Number(counts?.actsInFollowUp ?? 0), findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0), overdueControls: Number(counts?.overdueControls ?? 0), controlsNext30Days: Number(counts?.controlsNext30Days ?? 0), @@ -175,7 +211,7 @@ export class DashboardService { reportsOfficialized: Number(counts?.reportsOfficialized ?? 0), sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0), }, - recentAudit, + recentInspectorActivity, upcomingControls, generatedAt: new Date().toISOString(), }; diff --git a/api-v3/src/database/entities/asset.entity.ts b/api-v3/src/database/entities/asset.entity.ts index 870bf9a..79110e3 100644 --- a/api-v3/src/database/entities/asset.entity.ts +++ b/api-v3/src/database/entities/asset.entity.ts @@ -57,6 +57,9 @@ export class Asset extends TimestampedEntity { @Column({ name: 'inventory_family_id', type: 'uuid', nullable: true }) inventoryFamilyId!: string | null; + @Column({ name: 'is_inventory_instance', type: 'boolean', default: false }) + isInventoryInstance!: boolean; + @Column({ type: 'varchar', length: 120 }) code!: string; diff --git a/api-v3/src/database/migrations/1790087100000-f5-inventory-physical-instance.ts b/api-v3/src/database/migrations/1790087100000-f5-inventory-physical-instance.ts new file mode 100644 index 0000000..30a4098 --- /dev/null +++ b/api-v3/src/database/migrations/1790087100000-f5-inventory-physical-instance.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F5InventoryPhysicalInstance1790087100000 implements MigrationInterface { + name = 'F5InventoryPhysicalInstance1790087100000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE assets + ADD COLUMN is_inventory_instance boolean NOT NULL DEFAULT false + `); + await queryRunner.query(` + CREATE INDEX idx_assets_inventory_instance_active + ON assets (is_inventory_instance, information_status) + WHERE is_inventory_instance = true + `); + await queryRunner.query(` + COMMENT ON COLUMN assets.is_inventory_instance IS + 'True only for a concrete Instalacion/Subinstalacion instance. Empresa, Area and Yacimiento are structural/context masters.' + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION classify_new_asset_inventory_instance() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + type_code varchar; + BEGIN + SELECT lower(code) + INTO type_code + FROM asset_types + WHERE id = NEW.asset_type_id; + + NEW.is_inventory_instance := COALESCE(type_code, '') IN ('instalacion', 'subinstalacion'); + RETURN NEW; + END; + $$ + `); + await queryRunner.query(` + CREATE TRIGGER trg_assets_classify_inventory_instance + BEFORE INSERT OR UPDATE OF asset_type_id ON assets + FOR EACH ROW EXECUTE FUNCTION classify_new_asset_inventory_instance() + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TRIGGER IF EXISTS trg_assets_classify_inventory_instance ON assets'); + await queryRunner.query('DROP FUNCTION IF EXISTS classify_new_asset_inventory_instance()'); + await queryRunner.query('DROP INDEX IF EXISTS idx_assets_inventory_instance_active'); + await queryRunner.query('ALTER TABLE assets DROP COLUMN IF EXISTS is_inventory_instance'); + } +} diff --git a/api-v3/src/database/migrations/1790087150000-f5-canonical-inventory-hierarchy.ts b/api-v3/src/database/migrations/1790087150000-f5-canonical-inventory-hierarchy.ts new file mode 100644 index 0000000..b134a9e --- /dev/null +++ b/api-v3/src/database/migrations/1790087150000-f5-canonical-inventory-hierarchy.ts @@ -0,0 +1,192 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +type TypeRow = { + id: string; + code: string; + role: string; + active: boolean; + canBeRoot: boolean; +}; + +type RuleRow = { + childTypeId: string; + parentTypeId: string; +}; + +type CountRow = { total: number }; + +const CREATED_TYPES_TABLE = 'f5_canonical_hierarchy_created_types'; +const CREATED_RULES_TABLE = 'f5_canonical_hierarchy_created_rules'; + +const CANONICAL_TYPES = [ + { + code: 'yacimiento', + name: 'Yacimiento', + description: 'Yacimiento perteneciente a un Área.', + }, + { + code: 'instalacion', + name: 'Instalación', + description: 'Instancia física de una Instalación dentro de un Yacimiento.', + }, + { + code: 'subinstalacion', + name: 'Subinstalación', + description: 'Instancia física subordinada a una Instalación.', + }, +] as const; + +const CANONICAL_RULES = [ + ['yacimiento', 'area'], + ['instalacion', 'yacimiento'], + ['subinstalacion', 'instalacion'], +] as const; + +export class F5CanonicalInventoryHierarchy1790087150000 implements MigrationInterface { + name = 'F5CanonicalInventoryHierarchy1790087150000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE ${CREATED_TYPES_TABLE} ( + type_id uuid PRIMARY KEY, + code varchar(80) NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_f5_canonical_created_type FOREIGN KEY (type_id) + REFERENCES asset_types(id) ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE TABLE ${CREATED_RULES_TABLE} ( + child_type_id uuid NOT NULL, + parent_type_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (child_type_id,parent_type_id), + CONSTRAINT fk_f5_canonical_created_rule_child FOREIGN KEY (child_type_id) + REFERENCES asset_types(id) ON DELETE CASCADE, + CONSTRAINT fk_f5_canonical_created_rule_parent FOREIGN KEY (parent_type_id) + REFERENCES asset_types(id) ON DELETE CASCADE + ) + `); + + const area = await this.requireType(queryRunner, 'area'); + if (area.role !== 'AREA' || !area.active || !area.canBeRoot) { + throw new Error('F5 requires canonical active root type area with AREA operational role'); + } + + for (const definition of CANONICAL_TYPES) { + const inserted = (await queryRunner.query(` + INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role) + SELECT $1::varchar,$2::varchar,$3::text,false,true,'GENERIC' + WHERE NOT EXISTS ( + SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar) + ) + RETURNING id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot" + `, [definition.code, definition.name, definition.description])) as TypeRow[]; + + if (inserted[0]?.id) { + await queryRunner.query(` + INSERT INTO ${CREATED_TYPES_TABLE}(type_id,code) + VALUES ($1::uuid,$2::varchar) + `, [inserted[0].id, definition.code]); + } + + const type = await this.requireType(queryRunner, definition.code); + if (type.role !== 'GENERIC' || !type.active || type.canBeRoot) { + throw new Error(`F5 incompatible canonical type configuration: ${definition.code}`); + } + } + + for (const [childCode, parentCode] of CANONICAL_RULES) { + const inserted = (await queryRunner.query(` + INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id) + SELECT child.id,parent.id + FROM asset_types child CROSS JOIN asset_types parent + WHERE lower(child.code)=lower($1::varchar) + AND lower(parent.code)=lower($2::varchar) + AND NOT EXISTS ( + SELECT 1 FROM asset_type_parent_rules existing + WHERE existing.child_type_id=child.id AND existing.parent_type_id=parent.id + ) + RETURNING child_type_id AS "childTypeId",parent_type_id AS "parentTypeId" + `, [childCode, parentCode])) as RuleRow[]; + + if (inserted[0]?.childTypeId && inserted[0]?.parentTypeId) { + await queryRunner.query(` + INSERT INTO ${CREATED_RULES_TABLE}(child_type_id,parent_type_id) + VALUES ($1::uuid,$2::uuid) + `, [inserted[0].childTypeId, inserted[0].parentTypeId]); + } + } + + const [verified] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM asset_type_parent_rules rule + JOIN asset_types child ON child.id=rule.child_type_id + JOIN asset_types parent ON parent.id=rule.parent_type_id + WHERE (lower(child.code)='yacimiento' AND lower(parent.code)='area') + OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento') + OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion') + `)) as CountRow[]; + if (Number(verified?.total ?? 0) !== 3) { + throw new Error(`F5 canonical hierarchy verification failed: rules=${Number(verified?.total ?? 0)}`); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const [usedCreatedTypes] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM assets asset + JOIN ${CREATED_TYPES_TABLE} owned ON owned.type_id=asset.asset_type_id + `)) as CountRow[]; + if (Number(usedCreatedTypes?.total ?? 0) !== 0) { + throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type is already used by Inventory'); + } + + const [foreignRules] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM asset_type_parent_rules rule + WHERE ( + rule.child_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE}) + OR rule.parent_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE}) + ) + AND NOT EXISTS ( + SELECT 1 FROM ${CREATED_RULES_TABLE} owned + WHERE owned.child_type_id=rule.child_type_id + AND owned.parent_type_id=rule.parent_type_id + ) + `)) as CountRow[]; + if (Number(foreignRules?.total ?? 0) !== 0) { + throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type gained external parent rules'); + } + + await queryRunner.query(` + DELETE FROM asset_type_parent_rules rule + USING ${CREATED_RULES_TABLE} owned + WHERE rule.child_type_id=owned.child_type_id + AND rule.parent_type_id=owned.parent_type_id + `); + + await queryRunner.query(` + DELETE FROM asset_types type + USING ${CREATED_TYPES_TABLE} owned + WHERE type.id=owned.type_id + `); + + await queryRunner.query(`DROP TABLE ${CREATED_RULES_TABLE}`); + await queryRunner.query(`DROP TABLE ${CREATED_TYPES_TABLE}`); + } + + private async requireType(queryRunner: QueryRunner, code: string): Promise { + const rows = (await queryRunner.query(` + SELECT id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot" + FROM asset_types + WHERE lower(code)=lower($1::varchar) + ORDER BY created_at + `, [code])) as TypeRow[]; + + if (rows.length !== 1) { + throw new Error(`F5 requires exactly one canonical asset type ${code}; found ${rows.length}`); + } + return rows[0]; + } +} diff --git a/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts b/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts new file mode 100644 index 0000000..f457afb --- /dev/null +++ b/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts @@ -0,0 +1,602 @@ +import { createHash } from 'node:crypto'; +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { loadF5InventoryAuthoritativeSource } from '../../reference-data/f5-authoritative-inventory-source'; + +type IdRow = { id: string }; +type CountRow = { total: number }; + +const TERRITORY_DOCUMENT_NUMBER = 'DH-F5-TERRITORY'; +const TERRITORY_SOURCE_NAME = 'Tablas de yacimiento y areas.xlsx'; +const BACKUP_TABLE = 'f5_territory_relation_backups'; + +function key(value: string): string { + return value.normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +function code(prefix: string, value: string, length = 12): string { + return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`; +} + +function uniqueBy(values: T[], identity: (value: T) => string): T[] { + const seen = new Set(); + const output: T[] = []; + for (const value of values) { + const id = identity(value); + if (seen.has(id)) continue; + seen.add(id); + output.push(value); + } + return output; +} + +export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface { + name = 'F5AuthoritativeTerritory1790087200000'; + + public async up(queryRunner: QueryRunner): Promise { + const source = loadF5InventoryAuthoritativeSource(); + if ( + source.areaSource.file !== TERRITORY_SOURCE_NAME + || source.areaSource.sheet !== 'cr26e_tabla1' + || source.areaSource.sha256 !== '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78' + || source.areaSource.rows.length !== 230 + ) { + throw new Error('F5 territory source contract mismatch'); + } + + const rows = source.areaSource.rows; + const areaRows = uniqueBy(rows, (row) => key(row.area)); + const pairRows = uniqueBy(rows, (row) => `${key(row.area)}|${key(row.yacimiento)}`); + if (areaRows.length !== 64 || pairRows.length !== 230) { + throw new Error(`F5 territory cardinality mismatch: areas=${areaRows.length}, pairs=${pairRows.length}`); + } + + // Every Area must have one unambiguous source context. The workbook is the + // only authority for this preload; conflicting rows must abort the migration. + for (const areaRow of areaRows) { + const sameArea = rows.filter((row) => key(row.area)===key(areaRow.area)); + const dimensions = [ + new Set(sameArea.map((row) => key(row.departamento))), + new Set(sameArea.map((row) => key(row.tipoConcesion))), + new Set(sameArea.map((row) => key(row.empresaOperadora))), + ]; + if (dimensions.some((values) => values.size !== 1)) { + throw new Error(`F5 territory source has conflicting Area context: ${areaRow.area}`); + } + } + + await this.assertCanonicalTypesAndRules(queryRunner); + await this.installHierarchyGuard(queryRunner); + await this.ensureBackupTable(queryRunner); + + const preExistingDocument = await this.optionalId( + queryRunner, + `SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`, + [TERRITORY_DOCUMENT_NUMBER], + ); + if (preExistingDocument) { + throw new Error('F5 territory source document already exists before migration'); + } + + const insertedDocument = (await queryRunner.query(` + INSERT INTO source_documents ( + document_type, document_number, title, issuer, external_reference, notes + ) VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) + RETURNING id + `, [ + TERRITORY_DOCUMENT_NUMBER, + TERRITORY_SOURCE_NAME, + `sha256:${source.areaSource.sha256}`, + `F5 · fuente territorial autorizada · hoja ${source.areaSource.sheet} · ${rows.length} filas`, + ])) as IdRow[]; + const sourceDocumentId = insertedDocument[0]?.id; + if (!sourceDocumentId) throw new Error('F5 could not create territory source document'); + + const companyTypeId = await this.id( + queryRunner, + `SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`, + [], + 'COMPANY asset type', + ); + const areaTypeId = await this.id( + queryRunner, + `SELECT id FROM asset_types WHERE operational_role='AREA' AND is_active=true ORDER BY (lower(code)='area') DESC,created_at LIMIT 1`, + [], + 'AREA asset type', + ); + const fieldTypeId = await this.id( + queryRunner, + `SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`, + [], + 'Yacimiento asset type', + ); + + const companyNames = [...new Set(rows.map((row) => row.empresaOperadora.trim()))] + .filter((name) => name && key(name) !== key('Sin Empresa Operadora')) + .sort((a, b) => a.localeCompare(b, 'es')); + const companyIds = new Map(); + for (const companyName of companyNames) { + const companyId = await this.ensureRootAsset(queryRunner, { + typeId: companyTypeId, + role: 'COMPANY', + code: code('F5-ORG', key(companyName)), + name: companyName, + sourceDocumentId, + sourceReference: `F5:TERRITORY:COMPANY:${code('SRC', key(companyName), 10)}`, + }); + companyIds.set(key(companyName), companyId); + await queryRunner.query(` + INSERT INTO organization_profiles (asset_id,organization_kind,legal_name) + VALUES ($1::uuid,$2::organization_kind,$3) + ON CONFLICT (asset_id) DO NOTHING + `, [companyId, companyName.trim().toUpperCase().startsWith('UTE (') ? 'UTE' : 'COMPANY', companyName]); + } + + const departmentIds = new Map(); + for (const departmentName of [...new Set(rows.map((row) => row.departamento.trim()))].sort((a,b)=>a.localeCompare(b,'es'))) { + const normalized = key(departmentName); + let departmentId = await this.optionalId(queryRunner, ` + SELECT id FROM administrative_departments + WHERE province_code='MENDOZA' AND normalized_name=$1 AND is_active=true + LIMIT 1 + `, [normalized]); + if (!departmentId) { + const inserted = (await queryRunner.query(` + INSERT INTO administrative_departments ( + province_code,code,name,normalized_name,is_active,source_document_id + ) VALUES ('MENDOZA',$1,$2,$3,true,$4::uuid) + RETURNING id + `, [code('F5-DEP', normalized, 10), departmentName, normalized, sourceDocumentId])) as IdRow[]; + departmentId=inserted[0]?.id ?? null; + } + if (!departmentId) throw new Error(`F5 could not seed department ${departmentName}`); + departmentIds.set(normalized, departmentId); + } + + const areaIds = new Map(); + for (const areaRow of areaRows) { + const areaId = await this.ensureRootAsset(queryRunner, { + typeId: areaTypeId, + role: 'AREA', + code: code('F5-AREA', key(areaRow.area)), + name: areaRow.area, + sourceDocumentId, + sourceReference: `F5:TERRITORY:AREA:${code('SRC', key(areaRow.area), 10)}`, + }); + areaIds.set(key(areaRow.area), areaId); + + const departmentId = departmentIds.get(key(areaRow.departamento)); + if (!departmentId) throw new Error(`F5 missing department ${areaRow.departamento}`); + await this.backupAndCloseDepartmentRelations(queryRunner,areaId,departmentId); + await queryRunner.query(` + INSERT INTO area_department_relations ( + area_id,department_id,valid_from,source_document_id,notes + ) + SELECT $1::uuid,$2::uuid,CURRENT_DATE,$3::uuid,$4 + WHERE NOT EXISTS ( + SELECT 1 FROM area_department_relations + WHERE area_id=$1::uuid AND department_id=$2::uuid AND valid_until IS NULL + ) + `, [ + areaId, + departmentId, + sourceDocumentId, + `F5 · ${TERRITORY_SOURCE_NAME} · ${source.areaSource.sheet}`, + ]); + + const operatorId = companyIds.get(key(areaRow.empresaOperadora)) ?? null; + await this.backupAndCloseOperatorRelations(queryRunner,areaId,operatorId); + if (operatorId) { + await queryRunner.query(` + INSERT INTO area_company_relations ( + area_id,company_id,relation_role,source_document_id,valid_from,start_reason + ) + SELECT $1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4 + WHERE NOT EXISTS ( + SELECT 1 FROM area_company_relations + WHERE area_id=$1::uuid AND company_id=$2::uuid + AND relation_role='OPERATOR' AND valid_until IS NULL + ) + `, [ + areaId, + operatorId, + sourceDocumentId, + `F5 · operadora vigente según ${TERRITORY_SOURCE_NAME}`, + ]); + } + + const rightType = areaRow.tipoConcesion === 'Exploración' + ? 'EXPLORATION_PERMIT' + : areaRow.tipoConcesion === 'Explotación' + ? 'EXPLOITATION_CONCESSION' + : 'OTHER'; + const rightName = `${areaRow.tipoConcesion} · ${areaRow.area}`; + await queryRunner.query(` + INSERT INTO area_legal_rights ( + area_id,right_type,name,status,source_document_id,notes + ) + SELECT $1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5 + WHERE NOT EXISTS ( + SELECT 1 FROM area_legal_rights + WHERE area_id=$1::uuid + AND right_type=$2::area_legal_right_type + AND lower(btrim(name))=lower(btrim($3::varchar)) + AND status IN ('ACTIVE','PENDING') + ) + `, [ + areaId, + rightType, + rightName, + sourceDocumentId, + `F5 · tipo de concesión tomado literalmente de ${TERRITORY_SOURCE_NAME}`, + ]); + } + + for (const row of pairRows) { + const areaId = areaIds.get(key(row.area)); + if (!areaId) throw new Error(`F5 missing area ${row.area}`); + const sourceReference = `F5:TERRITORY:YAC:${code('SRC', `${key(row.area)}|${key(row.yacimiento)}`, 12)}`; + let yacimientoId = await this.optionalId(queryRunner, ` + SELECT asset.id + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE lower(type.code)='yacimiento' + AND asset.parent_id=$1::uuid + AND asset.information_status<>'INACTIVE' + AND lower(btrim(asset.name))=lower(btrim($2)) + ORDER BY asset.created_at + LIMIT 1 + `, [areaId, row.yacimiento]); + if (!yacimientoId) { + const inserted = (await queryRunner.query(` + INSERT INTO assets ( + asset_type_id,parent_id,operational_area_id,operator_company_id, + code,name,description,information_status,operational_status, + data_origin,source_name,source_reference,source_notes,is_inventory_instance + ) VALUES ( + $1::uuid,$2::uuid,NULL,NULL,$3,$4,$5,'VALIDATED','UNKNOWN', + 'PROVIDED_DOCUMENT',$6,$7,$8,false + ) RETURNING id + `, [ + fieldTypeId, + areaId, + code('F5-YAC', `${key(row.area)}|${key(row.yacimiento)}`), + row.yacimiento, + `Yacimiento del Área ${row.area}`, + TERRITORY_SOURCE_NAME, + sourceReference, + `${source.areaSource.sheet} · fila ${row.sourceRow}`, + ])) as IdRow[]; + yacimientoId = inserted[0]?.id ?? null; + } + if (!yacimientoId) throw new Error(`F5 could not seed yacimiento ${row.area} / ${row.yacimiento}`); + await this.linkSource(queryRunner, yacimientoId, sourceDocumentId, `Hoja ${source.areaSource.sheet} · fila ${row.sourceRow}`); + } + + const [verification] = (await queryRunner.query(` + SELECT + COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA')::integer AS areas, + COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')::integer AS yacimientos + FROM asset_source_documents link + JOIN assets asset ON asset.id=link.asset_id + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE link.document_id=$1::uuid + `, [sourceDocumentId])) as Array<{ areas: number; yacimientos: number }>; + if (Number(verification?.areas ?? 0) !== 64 || Number(verification?.yacimientos ?? 0) !== 230) { + throw new Error(`F5 territory preload verification failed: ${JSON.stringify(verification ?? {})}`); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const sourceDocumentId = await this.optionalId( + queryRunner, + `SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`, + [TERRITORY_DOCUMENT_NUMBER], + ); + if (!sourceDocumentId) { + await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets'); + await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()'); + await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`); + return; + } + + await this.assertRelationBackupsUnchanged(queryRunner); + await this.assertCreatedMastersUnused(queryRunner,sourceDocumentId); + + await queryRunner.query(`DELETE FROM area_legal_rights WHERE source_document_id=$1::uuid`,[sourceDocumentId]); + + // Remove relations created by F5 first so restoring the previously-active + // relation cannot violate active-relation uniqueness constraints. + await queryRunner.query(`DELETE FROM area_company_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]); + await queryRunner.query(`DELETE FROM area_department_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]); + await this.restoreRelationBackups(queryRunner); + + await queryRunner.query(`DELETE FROM asset_source_documents WHERE document_id=$1::uuid`,[sourceDocumentId]); + await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:YAC:%'`); + await queryRunner.query(`DELETE FROM organization_profiles profile USING assets asset WHERE profile.asset_id=asset.id AND asset.source_reference LIKE 'F5:TERRITORY:COMPANY:%'`); + await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:COMPANY:%'`); + await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:AREA:%'`); + await queryRunner.query(`DELETE FROM administrative_departments WHERE source_document_id=$1::uuid`,[sourceDocumentId]); + await queryRunner.query(`DELETE FROM source_documents WHERE id=$1::uuid`,[sourceDocumentId]); + + await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets'); + await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()'); + await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`); + } + + private async assertCanonicalTypesAndRules(queryRunner: QueryRunner): Promise { + const [roles] = (await queryRunner.query(` + SELECT + COUNT(*) FILTER (WHERE operational_role='AREA' AND is_active=true)::integer AS areas, + COUNT(*) FILTER (WHERE operational_role='COMPANY' AND is_active=true)::integer AS companies + FROM asset_types + `)) as Array<{areas:number; companies:number}>; + if (Number(roles?.areas ?? 0)<1 || Number(roles?.companies ?? 0)<1) { + throw new Error('F5 requires active AREA and COMPANY master types'); + } + + for (const [typeCode,typeName,description] of [ + ['yacimiento','Yacimiento','Yacimiento perteneciente a un Área.'], + ['instalacion','Instalación','Instancia física de una Instalación dentro de un Yacimiento.'], + ['subinstalacion','Subinstalación','Instancia física subordinada a una Instalación.'], + ] as const) { + await queryRunner.query(` + INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role) + SELECT $1::varchar,$2,$3,false,true,'GENERIC' + WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar)) + `,[typeCode,typeName,description]); + const [type] = (await queryRunner.query(` + SELECT operational_role AS role,is_active AS active,can_be_root AS "canBeRoot" + FROM asset_types WHERE lower(code)=lower($1) LIMIT 1 + `,[typeCode])) as Array<{role:string;active:boolean;canBeRoot:boolean}>; + if (!type || type.role!=='GENERIC' || !type.active || type.canBeRoot) { + throw new Error(`F5 incompatible master type configuration: ${typeCode}`); + } + } + + const [ruleCount] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM asset_type_parent_rules rule + JOIN asset_types child ON child.id=rule.child_type_id + JOIN asset_types parent ON parent.id=rule.parent_type_id + WHERE (lower(child.code)='yacimiento' AND lower(parent.code)='area') + OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento') + OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion') + `)) as CountRow[]; + if (Number(ruleCount?.total ?? 0)!==3) { + throw new Error('F5 requires canonical parent rules Area → Yacimiento → Instalación → Subinstalación'); + } + } + + private async installHierarchyGuard(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE child_code text; parent_code text; + BEGIN + SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id; + IF child_code IN ('empresa','organizacion','area') THEN + IF NEW.parent_id IS NOT NULL THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Área son maestros raíz independientes'; + END IF; + RETURN NEW; + END IF; + IF child_code NOT IN ('yacimiento','instalacion','subinstalacion') THEN RETURN NEW; END IF; + IF NEW.parent_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento, Instalación y Subinstalación requieren padre'; + END IF; + SELECT lower(type.code) INTO parent_code + FROM assets parent JOIN asset_types type ON type.id=parent.asset_type_id + WHERE parent.id=NEW.parent_id; + IF (child_code='yacimiento' AND parent_code<>'area') + OR (child_code='instalacion' AND parent_code<>'yacimiento') + OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía F5 inválida: Área → Yacimiento → Instalación → Subinstalación'; + END IF; + RETURN NEW; + END $$; + `); + await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets'); + await queryRunner.query(` + CREATE TRIGGER trg_f5_canonical_asset_hierarchy + BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets + FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy() + `); + } + + private async ensureBackupTable(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE ${BACKUP_TABLE} ( + relation_kind varchar(32) NOT NULL, + relation_id uuid NOT NULL, + previous_values jsonb NOT NULL, + applied_values jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (relation_kind,relation_id), + CONSTRAINT chk_f5_territory_backup_kind CHECK (relation_kind IN ('AREA_COMPANY','AREA_DEPARTMENT')) + ) + `); + } + + private async backupAndCloseDepartmentRelations(queryRunner: QueryRunner,areaId:string,departmentId:string):Promise { + const marker='F5: reemplazada por fuente territorial autorizada'; + await queryRunner.query(` + INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values) + SELECT 'AREA_DEPARTMENT',relation.id, + jsonb_build_object('validUntil',relation.valid_until,'notes',relation.notes), + jsonb_build_object('validUntil',CURRENT_DATE,'notes',concat_ws(E'\n',relation.notes,$3::text)) + FROM area_department_relations relation + WHERE relation.area_id=$1::uuid + AND relation.valid_until IS NULL + AND relation.department_id<>$2::uuid + ON CONFLICT DO NOTHING + `,[areaId,departmentId,marker]); + await queryRunner.query(` + UPDATE area_department_relations relation + SET valid_until=(backup.applied_values->>'validUntil')::date, + notes=backup.applied_values->>'notes', + updated_at=CURRENT_TIMESTAMP + FROM ${BACKUP_TABLE} backup + WHERE backup.relation_kind='AREA_DEPARTMENT' + AND backup.relation_id=relation.id + AND relation.area_id=$1::uuid + AND relation.valid_until IS NULL + `,[areaId]); + } + + private async backupAndCloseOperatorRelations(queryRunner: QueryRunner,areaId:string,operatorId:string|null):Promise { + const marker='F5: reemplazada por fuente territorial autorizada'; + await queryRunner.query(` + INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values) + SELECT 'AREA_COMPANY',relation.id, + jsonb_build_object('validUntil',relation.valid_until,'endReason',relation.end_reason), + jsonb_build_object('validUntil',CURRENT_TIMESTAMP,'endReason',$3::text) + FROM area_company_relations relation + WHERE relation.area_id=$1::uuid + AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL + AND ($2::uuid IS NULL OR relation.company_id<>$2::uuid) + ON CONFLICT DO NOTHING + `,[areaId,operatorId,marker]); + await queryRunner.query(` + UPDATE area_company_relations relation + SET valid_until=(backup.applied_values->>'validUntil')::timestamptz, + end_reason=backup.applied_values->>'endReason', + updated_at=CURRENT_TIMESTAMP + FROM ${BACKUP_TABLE} backup + WHERE backup.relation_kind='AREA_COMPANY' + AND backup.relation_id=relation.id + AND relation.area_id=$1::uuid + AND relation.valid_until IS NULL + `,[areaId]); + } + + private async assertRelationBackupsUnchanged(queryRunner: QueryRunner):Promise { + const [changed] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM ${BACKUP_TABLE} backup + LEFT JOIN area_company_relations company_relation + ON backup.relation_kind='AREA_COMPANY' AND company_relation.id=backup.relation_id + LEFT JOIN area_department_relations department_relation + ON backup.relation_kind='AREA_DEPARTMENT' AND department_relation.id=backup.relation_id + WHERE ( + backup.relation_kind='AREA_COMPANY' + AND ( + company_relation.id IS NULL + OR company_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::timestamptz + OR company_relation.end_reason IS DISTINCT FROM backup.applied_values->>'endReason' + ) + ) OR ( + backup.relation_kind='AREA_DEPARTMENT' + AND ( + department_relation.id IS NULL + OR department_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::date + OR department_relation.notes IS DISTINCT FROM backup.applied_values->>'notes' + ) + ) + `)) as CountRow[]; + if (Number(changed?.total ?? 0)>0) { + throw new Error('Cannot safely rollback F5 territory: a relation closed by the preload was modified afterwards'); + } + } + + private async restoreRelationBackups(queryRunner: QueryRunner):Promise { + await queryRunner.query(` + UPDATE area_company_relations relation + SET valid_until=(backup.previous_values->>'validUntil')::timestamptz, + end_reason=backup.previous_values->>'endReason', + updated_at=CURRENT_TIMESTAMP + FROM ${BACKUP_TABLE} backup + WHERE backup.relation_kind='AREA_COMPANY' AND backup.relation_id=relation.id + `); + await queryRunner.query(` + UPDATE area_department_relations relation + SET valid_until=(backup.previous_values->>'validUntil')::date, + notes=backup.previous_values->>'notes', + updated_at=CURRENT_TIMESTAMP + FROM ${BACKUP_TABLE} backup + WHERE backup.relation_kind='AREA_DEPARTMENT' AND backup.relation_id=relation.id + `); + } + + private async assertCreatedMastersUnused(queryRunner:QueryRunner,sourceDocumentId:string):Promise { + const [used] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM assets asset + WHERE asset.source_reference LIKE 'F5:TERRITORY:%' + AND ( + EXISTS (SELECT 1 FROM assets child WHERE child.parent_id=asset.id AND child.source_reference NOT LIKE 'F5:TERRITORY:%') + OR EXISTS (SELECT 1 FROM inspection_visits visit WHERE visit.operational_area_id=asset.id OR visit.operator_company_id=asset.id) + OR EXISTS (SELECT 1 FROM inspection_visit_assets link WHERE link.asset_id=asset.id) + OR EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.asset_id=asset.id) + ) + `)) as CountRow[]; + if (Number(used?.total ?? 0)>0) { + throw new Error('Cannot safely rollback F5 territory: F5-created master data is already used by operational records'); + } + void sourceDocumentId; + } + + private async ensureRootAsset( + queryRunner: QueryRunner, + input: { + typeId: string; + role: 'AREA' | 'COMPANY'; + code: string; + name: string; + sourceDocumentId: string; + sourceReference: string; + }, + ): Promise { + let assetId = await this.optionalId(queryRunner, ` + SELECT asset.id + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE type.operational_role=$1::asset_type_operational_role + AND asset.information_status<>'INACTIVE' + AND lower(btrim(asset.name))=lower(btrim($2)) + ORDER BY asset.created_at + LIMIT 1 + `,[input.role,input.name]); + if (!assetId) { + const inserted = (await queryRunner.query(` + INSERT INTO assets ( + asset_type_id,parent_id,operational_area_id,operator_company_id, + code,name,information_status,operational_status,data_origin, + source_name,source_reference,source_notes,is_inventory_instance + ) VALUES ($1::uuid,NULL,NULL,NULL,$2,$3,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$4,$5,$6,false) + RETURNING id + `,[ + input.typeId,input.code,input.name,TERRITORY_SOURCE_NAME,input.sourceReference, + 'F5 · fuente territorial autorizada', + ])) as IdRow[]; + assetId=inserted[0]?.id ?? null; + } + if (!assetId) throw new Error(`F5 could not seed ${input.role} ${input.name}`); + await this.linkSource(queryRunner,assetId,input.sourceDocumentId,'F5 · fuente territorial autorizada'); + return assetId; + } + + private async linkSource(queryRunner:QueryRunner,assetId:string,documentId:string,notes:string):Promise { + await queryRunner.query(` + INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes) + VALUES ($1::uuid,$2::uuid,'SOURCE',$3) + ON CONFLICT (asset_id,document_id,relation_type) DO UPDATE SET notes=EXCLUDED.notes,updated_at=CURRENT_TIMESTAMP + `,[assetId,documentId,notes]); + } + + private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise { + const value=await this.optionalId(queryRunner,sql,params); + if (!value) throw new Error(`F5 could not resolve ${label}`); + return value; + } + + private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise { + const result=(await queryRunner.query(sql,params)) as IdRow[]; + return result[0]?.id ?? null; + } +} diff --git a/api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts b/api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts new file mode 100644 index 0000000..aa61a4e --- /dev/null +++ b/api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts @@ -0,0 +1,182 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * F5 makes the physical hierarchy Area-owned. Empresa is never a parent nor a + * required property of Yacimiento/Instalación/Subinstalación. The current and + * historical operator/concession truth lives in area_company_relations and is + * frozen separately by each Inspección/Acta. + * + * operator_company_id is retained only as a backwards-compatible creation/ + * historical snapshot. Runtime ownership and search MUST NOT depend on it. + */ +export class F5OperationalContextCompatibility1790087250000 implements MigrationInterface { + name = 'F5OperationalContextCompatibility1790087250000'; + + public async up(queryRunner: QueryRunner): Promise { + await this.installAreaOwnedGuard(queryRunner); + } + + public async down(queryRunner: QueryRunner): Promise { + await this.installLegacyPairedGuard(queryRunner); + } + + private async installAreaOwnedGuard(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_asset_operational_context() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE + asset_role asset_type_operational_role; + area_role asset_type_operational_role; + company_role asset_type_operational_role; + active_relation_id uuid; + BEGIN + SELECT operational_role INTO asset_role + FROM asset_types WHERE id=NEW.asset_type_id; + + IF asset_role <> 'GENERIC'::asset_type_operational_role THEN + IF NEW.operational_area_id IS NOT NULL OR NEW.operator_company_id IS NOT NULL THEN + RAISE EXCEPTION USING ERRCODE='23514', + MESSAGE='Área y Empresa no reciben contexto operativo de Inventario'; + END IF; + RETURN NEW; + END IF; + + IF NEW.operator_company_id IS NOT NULL AND NEW.operational_area_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE='23514', + MESSAGE='Un snapshot de Empresa requiere un Área física'; + END IF; + + -- Once written, an old/current company snapshot cannot be repointed to + -- simulate physical ownership. Company changes happen in the temporal + -- Area↔Empresa relation instead. + IF TG_OP='UPDATE' + AND NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN + RAISE EXCEPTION USING ERRCODE='23514', + MESSAGE='La Empresa se cambia en la relación temporal del Área, no en el Inventario'; + END IF; + + IF NEW.operational_area_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT type.operational_role INTO area_role + FROM assets area + JOIN asset_types type ON type.id=area.asset_type_id + WHERE area.id=NEW.operational_area_id + AND area.information_status<>'INACTIVE' + AND type.is_active=true; + IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE='23514', + MESSAGE='operational area must be an active AREA asset'; + END IF; + + IF NEW.parent_id IS NULL OR NOT EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=NEW.parent_id + UNION ALL + SELECT parent.id,parent.parent_id + FROM assets parent + JOIN ancestors child ON parent.id=child.parent_id + ) + SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1 + ) THEN + RAISE EXCEPTION USING ERRCODE='23514', + MESSAGE='operational area must be an ancestor in the physical hierarchy'; + END IF; + + -- A company value is allowed only as the context snapshot that was valid + -- at creation time. It is never used to decide future membership. + IF NEW.operator_company_id IS NOT NULL THEN + SELECT type.operational_role INTO company_role + FROM assets company + JOIN asset_types type ON type.id=company.asset_type_id + WHERE company.id=NEW.operator_company_id + AND company.information_status<>'INACTIVE' + AND type.is_active=true; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE='23514', + MESSAGE='operator snapshot must reference an active COMPANY-role asset'; + END IF; + + IF TG_OP='INSERT' THEN + SELECT relation.id INTO active_relation_id + FROM area_company_relations relation + WHERE relation.area_id=NEW.operational_area_id + AND relation.company_id=NEW.operator_company_id + AND relation.relation_role='OPERATOR'::area_organization_role + AND relation.valid_until IS NULL + FOR KEY SHARE; + IF active_relation_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE='23514', + MESSAGE='creation operator snapshot must be active for the selected Area'; + END IF; + END IF; + END IF; + + RETURN NEW; + END $$; + `); + } + + /** Restores the production F4-era paired Area+Empresa guard on rollback. */ + private async installLegacyPairedGuard(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_asset_operational_context() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE + asset_role asset_type_operational_role; + area_role asset_type_operational_role; + company_role asset_type_operational_role; + active_relation_id uuid; + BEGIN + IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN + RETURN NEW; + END IF; + IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE='23514', + MESSAGE='operational area and organization must be assigned together'; + END IF; + + SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id; + IF asset_role <> 'GENERIC'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and organization assets cannot receive an operational assignment'; + END IF; + + SELECT type.operational_role INTO area_role + FROM assets area JOIN asset_types type ON type.id=area.asset_type_id + WHERE area.id=NEW.operational_area_id AND area.information_status<>'INACTIVE' AND type.is_active=true; + SELECT type.operational_role INTO company_role + FROM assets company JOIN asset_types type ON type.id=company.asset_type_id + WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true; + IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset'; + END IF; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator organization must be an active COMPANY-role asset'; + END IF; + + SELECT relation.id INTO active_relation_id + FROM area_company_relations relation + WHERE relation.area_id=NEW.operational_area_id + AND relation.company_id=NEW.operator_company_id + AND relation.relation_role='OPERATOR'::area_organization_role + AND relation.valid_until IS NULL + FOR KEY SHARE; + IF active_relation_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization do not have an active OPERATOR relation'; + END IF; + + IF NEW.parent_id IS NULL OR NOT EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=NEW.parent_id + UNION ALL + SELECT parent.id,parent.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id + ) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1 + ) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy'; + END IF; + RETURN NEW; + END $$; + `); + } +} diff --git a/api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts b/api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts new file mode 100644 index 0000000..1e42f93 --- /dev/null +++ b/api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts @@ -0,0 +1,606 @@ +import { createHash } from 'node:crypto'; +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { + loadF5InventoryAuthoritativeSource, + type F5InstallationCatalogRow, + type F5SubinstallationCatalogRow, +} from '../../reference-data/f5-authoritative-inventory-source'; + +type IdRow = { id: string }; +type CountRow = { total: number }; + +const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG'; +const CATALOG_CATEGORY_CODE = 'F5MODEL'; +const CATALOG_SOURCE_NAME = 'final_modelov2.xlsx'; +const F5_AUTO_REASON = 'F5 familia técnica: catálogo contextual automático'; +const F5_SOURCE_FAMILY_COUNT = 123; + +function findingKey(value: string): string { + return value.normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +function hashCode(prefix: string, value: string, length = 12): string { + return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`; +} + +function installationCode(name: string): string { + return hashCode('F5-I', findingKey(name)); +} + +function subinstallationCode(installation: string, name: string): string { + return hashCode('F5-S', `${findingKey(installation)}|${findingKey(name)}`); +} + +function subOtherCode(parentCode: string): string { + return hashCode('F5-S-OTRO', parentCode); +} + +export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationInterface { + name = 'F5AuthoritativeInventoryCatalog1790087300000'; + + public async up(queryRunner: QueryRunner): Promise { + const source = loadF5InventoryAuthoritativeSource(); + if ( + source.catalogSource.file !== CATALOG_SOURCE_NAME + || source.catalogSource.sheet !== 'Hoja1' + || source.catalogSource.sha256 !== 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155' + || source.catalogSource.installations.length !== 14 + || source.catalogSource.subinstallations.length !== 109 + ) { + throw new Error('F5 inventory catalog source contract mismatch'); + } + + if (source.catalogSource.universalFindings.length !== 3) { + throw new Error(`F5 universal finding contract mismatch: ${source.catalogSource.universalFindings.length}`); + } + const universalKeys = new Set(source.catalogSource.universalFindings.map(findingKey)); + for (const required of [ + 'ORDEN Y LIMPIEZA', + 'CARTELERIA PREVENTIVA / INFORMATIVA', + 'EXTINTORES', + ]) { + if (!universalKeys.has(findingKey(required))) { + throw new Error(`F5 missing authoritative universal finding: ${required}`); + } + } + + await queryRunner.query(` + INSERT INTO source_documents ( + document_type,document_number,title,issuer,external_reference,notes + ) + VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) + ON CONFLICT (document_number,issuer) WHERE document_number IS NOT NULL AND issuer IS NOT NULL + DO UPDATE SET + title=EXCLUDED.title, + external_reference=EXCLUDED.external_reference, + notes=EXCLUDED.notes, + updated_at=CURRENT_TIMESTAMP + `, [ + CATALOG_DOCUMENT_NUMBER, + CATALOG_SOURCE_NAME, + `sha256:${source.catalogSource.sha256}`, + `F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`, + ]); + + // Only the known historical spreadsheet catalog is superseded. Families + // created manually by DH (including source_reference NULL) remain untouched. + await queryRunner.query(` + UPDATE inventory_families + SET is_active=false,updated_at=CURRENT_TIMESTAMP + WHERE source_reference LIKE 'APLICACION APP%' + OR source_reference LIKE 'SYSTEM:F3.1:%' + `); + await queryRunner.query(` + UPDATE finding_categories + SET is_active=false,updated_at=CURRENT_TIMESTAMP + WHERE lower(code) IN ('app26','app26r2') + `); + + const installationIds = new Map(); + for (const installation of source.catalogSource.installations) { + const familyId = await this.upsertFamily( + queryRunner, + installationCode(installation.name), + installation.name, + 'INSTALLATION', + `F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${installation.sourceStartRow}-${installation.sourceEndRow}`, + ); + installationIds.set(findingKey(installation.name),familyId); + } + + for (const subinstallation of source.catalogSource.subinstallations) { + const parentId = installationIds.get(findingKey(subinstallation.installation)); + if (!parentId) throw new Error(`F5 missing installation family ${subinstallation.installation}`); + const childId = await this.upsertFamily( + queryRunner, + subinstallationCode(subinstallation.installation,subinstallation.name), + subinstallation.name, + 'SUBINSTALLATION', + `F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${subinstallation.sourceStartRow}-${subinstallation.sourceEndRow}${subinstallation.reference ? `|reference:${subinstallation.reference}` : ''}`, + ); + await this.parentRule(queryRunner,childId,parentId); + } + + const installationOtherId = await this.upsertFamily( + queryRunner, + 'F5-I-OTRO', + 'Otro / no catalogado', + 'INSTALLATION', + 'F5:SYSTEM:OTHER:INSTALLATION', + ); + for (const [installationKey,parentId] of installationIds) { + const parent = source.catalogSource.installations.find((item) => findingKey(item.name)===installationKey); + if (!parent) continue; + const childId = await this.upsertFamily( + queryRunner, + subOtherCode(installationCode(parent.name)), + 'Otro / no catalogado', + 'SUBINSTALLATION', + `F5:SYSTEM:OTHER:SUBINSTALLATION:${installationCode(parent.name)}`, + ); + await this.parentRule(queryRunner,childId,parentId); + } + const rootOtherChild = await this.upsertFamily( + queryRunner, + subOtherCode('F5-I-OTRO'), + 'Otro / no catalogado', + 'SUBINSTALLATION', + 'F5:SYSTEM:OTHER:SUBINSTALLATION:F5-I-OTRO', + ); + await this.parentRule(queryRunner,rootOtherChild,installationOtherId); + + await queryRunner.query(` + INSERT INTO finding_categories(code,name,sort_order,is_active) + SELECT $1::varchar,'DH · Modelo de Inventarios F5',270,true + WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)=lower($1::varchar)) + `, [CATALOG_CATEGORY_CODE]); + await queryRunner.query(` + UPDATE finding_categories + SET name='DH · Modelo de Inventarios F5',sort_order=270,is_active=true,updated_at=CURRENT_TIMESTAMP + WHERE lower(code)=lower($1::varchar) + `,[CATALOG_CATEGORY_CODE]); + const categoryId = await this.id( + queryRunner, + `SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`, + [CATALOG_CATEGORY_CODE], + 'F5 finding category', + ); + + const titleByKey = new Map(); + const register = (title: string): void => { + const clean = title.trim(); + if (!clean || /^idem\b/i.test(clean) || findingKey(clean)==='hallazgos') return; + const itemKey = findingKey(clean); + if (!titleByKey.has(itemKey)) titleByKey.set(itemKey,clean); + }; + for (const title of source.catalogSource.universalFindings) register(title); + for (const family of source.catalogSource.installations) for (const title of family.findings) register(title); + for (const family of source.catalogSource.subinstallations) for (const title of family.findings) register(title); + if (titleByKey.size !== 177) { + throw new Error(`F5 finding normalization contract mismatch: ${titleByKey.size}`); + } + + const itemIdByKey = new Map(); + const orderedTitles = [...titleByKey.entries()].sort((a,b)=>a[1].localeCompare(b[1],'es')); + let sourceNumber=1; + for (const [itemKey,title] of orderedTitles) { + const itemCode = hashCode('F5-H',itemKey); + let itemId = await this.optionalId( + queryRunner, + `SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::varchar) LIMIT 1`, + [itemCode], + ); + if (!itemId) { + const itemRows = (await queryRunner.query(` + INSERT INTO finding_catalog_items ( + category_id,code,source_number,title,import_note,revision,is_active + ) VALUES ($1::uuid,$2,$3,$4,$5,1,true) + RETURNING id + `,[ + categoryId,itemCode,sourceNumber,title, + `${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`, + ])) as IdRow[]; + itemId=itemRows[0]?.id ?? null; + } else { + await queryRunner.query(` + UPDATE finding_catalog_items + SET category_id=$2::uuid,source_number=$3,title=$4,import_note=$5, + is_active=true,updated_at=CURRENT_TIMESTAMP + WHERE id=$1::uuid + `,[ + itemId,categoryId,sourceNumber,title, + `${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`, + ]); + } + if (!itemId) throw new Error(`F5 could not create finding ${title}`); + itemIdByKey.set(itemKey,itemId); + await queryRunner.query(` + INSERT INTO finding_catalog_item_versions(item_id,revision,snapshot,actor_username) + SELECT item.id,item.revision, + jsonb_build_object( + 'id',item.id,'categoryId',category.id,'categoryCode',category.code, + 'categoryName',category.name,'code',item.code,'sourceNumber',item.source_number, + 'title',item.title,'legalBasis',item.legal_basis,'glossary',item.glossary, + 'importNote',item.import_note,'revision',item.revision,'isActive',item.is_active + ),'migration:F5' + FROM finding_catalog_items item + JOIN finding_categories category ON category.id=item.category_id + WHERE item.id=$1::uuid + AND NOT EXISTS ( + SELECT 1 FROM finding_catalog_item_versions version + WHERE version.item_id=item.id AND version.revision=item.revision + ) + `,[itemId]); + sourceNumber+=1; + } + + // Add F5 mappings only. Never delete mappings created by office users or by + // historical migrations; inactive historical families simply stop being offered. + for (const family of source.catalogSource.installations) { + await this.mapFindings( + queryRunner, + installationCode(family.name), + family, + source.catalogSource.universalFindings, + itemIdByKey, + ); + } + for (const family of source.catalogSource.subinstallations) { + await this.mapFindings( + queryRunner, + subinstallationCode(family.installation,family.name), + family, + source.catalogSource.universalFindings, + itemIdByKey, + ); + } + + await this.installFamilySyncFunctions(queryRunner); + + // Keep pre-existing profile administration untouched. Yacimiento needs a + // profile only to expose OTROS because the source does not provide a family. + await queryRunner.query(` + INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason) + SELECT id,'F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.' + FROM asset_types WHERE lower(code)='yacimiento' + ON CONFLICT (asset_type_id) DO NOTHING + `); + + const [counts] = (await queryRunner.query(` + SELECT + COUNT(*) FILTER (WHERE level='INSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS installations, + COUNT(*) FILTER (WHERE level='SUBINSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS subinstallations + FROM inventory_families WHERE is_active=true + `)) as Array<{ installations:number; subinstallations:number }>; + if (Number(counts?.installations ?? 0)!==14 || Number(counts?.subinstallations ?? 0)!==109) { + throw new Error(`F5 family preload verification failed: ${JSON.stringify(counts ?? {})}`); + } + + const [itemCount] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM finding_catalog_items + WHERE category_id=$1::uuid AND is_active=true + `,[categoryId])) as CountRow[]; + if (Number(itemCount?.total ?? 0)!==177) { + throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`); + } + + // Verify every universal finding is independently attached to every one of + // the 14 + 109 source families. This intentionally avoids optional DB text + // extensions such as unaccent. + for (const universalTitle of source.catalogSource.universalFindings) { + const universalItemId = itemIdByKey.get(findingKey(universalTitle)); + if (!universalItemId) throw new Error(`F5 missing universal catalog item ${universalTitle}`); + const [mappedCount] = (await queryRunner.query(` + SELECT COUNT(DISTINCT mapping.inventory_family_id)::integer AS total + FROM finding_catalog_item_inventory_families mapping + JOIN inventory_families family ON family.id=mapping.inventory_family_id + WHERE mapping.catalog_item_id=$1::uuid + AND family.is_active=true + AND family.source_reference LIKE $2 + `,[universalItemId,`F5:${CATALOG_SOURCE_NAME}%`])) as CountRow[]; + if (Number(mappedCount?.total ?? 0)!==F5_SOURCE_FAMILY_COUNT) { + throw new Error(`F5 universal mapping verification failed for ${universalTitle}: ${mappedCount?.total ?? 0}`); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + const categoryId = await this.optionalId( + queryRunner, + `SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`, + [CATALOG_CATEGORY_CODE], + ); + + if (categoryId) { + const [usedFinding] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM inspection_findings finding + JOIN finding_catalog_items item ON item.id=finding.catalog_item_id + WHERE item.category_id=$1::uuid + `,[categoryId])) as CountRow[]; + if (Number(usedFinding?.total ?? 0)>0) { + throw new Error('Cannot safely rollback F5 catalog: inspection findings already reference F5 catalog items'); + } + } + + const [usedFamily] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM assets asset + JOIN inventory_families family ON family.id=asset.inventory_family_id + WHERE family.source_reference LIKE 'F5:%' + `)) as CountRow[]; + if (Number(usedFamily?.total ?? 0)>0) { + throw new Error('Cannot safely rollback F5 catalog: inventory instances already reference F5 families'); + } + + await queryRunner.query(` + DELETE FROM finding_catalog_asset_overrides + WHERE reason=$1::text + `,[F5_AUTO_REASON]); + + if (categoryId) { + await queryRunner.query(` + DELETE FROM finding_catalog_item_inventory_families mapping + USING finding_catalog_items item + WHERE item.id=mapping.catalog_item_id AND item.category_id=$1::uuid + `,[categoryId]); + await queryRunner.query(` + DELETE FROM finding_catalog_item_versions version + USING finding_catalog_items item + WHERE item.id=version.item_id AND item.category_id=$1::uuid + `,[categoryId]); + await queryRunner.query(`DELETE FROM finding_catalog_items WHERE category_id=$1::uuid`,[categoryId]); + await queryRunner.query(`DELETE FROM finding_categories WHERE id=$1::uuid`,[categoryId]); + } + + await queryRunner.query(` + DELETE FROM inventory_family_parent_rules rule + USING inventory_families child + WHERE child.id=rule.child_family_id AND child.source_reference LIKE 'F5:%' + `); + await queryRunner.query(`DELETE FROM inventory_families WHERE source_reference LIKE 'F5:%'`); + await queryRunner.query(` + UPDATE inventory_families + SET is_active=true,updated_at=CURRENT_TIMESTAMP + WHERE source_reference LIKE 'APLICACION APP%' + OR source_reference LIKE 'SYSTEM:F3.1:%' + `); + await queryRunner.query(` + UPDATE finding_categories SET is_active=true,updated_at=CURRENT_TIMESTAMP + WHERE lower(code)='app26r2' + `); + await queryRunner.query(` + DELETE FROM finding_catalog_asset_type_profiles profile + USING asset_types type + WHERE profile.asset_type_id=type.id + AND lower(type.code)='yacimiento' + AND profile.reason='F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.' + `); + await queryRunner.query(` + DELETE FROM source_documents + WHERE document_number=$1::varchar AND issuer='Dirección de Hidrocarburos' + `,[CATALOG_DOCUMENT_NUMBER]); + await this.restoreF31FamilySyncFunctions(queryRunner); + + // Rebuild only automatic historical overrides. Manual overrides have never + // been touched by this migration. + await queryRunner.query(` + DELETE FROM finding_catalog_asset_overrides + WHERE reason LIKE 'F3.1 familia técnica:%' + `); + await queryRunner.query(` + INSERT INTO finding_catalog_asset_overrides( + asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by + ) + SELECT asset.id,mapping.catalog_item_id,true, + 'F3.1 familia técnica: catálogo contextual automático', + asset.created_by,asset.updated_by + FROM assets asset + JOIN finding_catalog_item_inventory_families mapping + ON mapping.inventory_family_id=asset.inventory_family_id + WHERE asset.inventory_family_id IS NOT NULL + ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET + is_enabled=true, + reason='F3.1 familia técnica: catálogo contextual automático', + updated_at=CURRENT_TIMESTAMP + `); + } + + private async upsertFamily( + queryRunner: QueryRunner, + familyCode: string, + name: string, + level: 'INSTALLATION'|'SUBINSTALLATION', + sourceReference: string, + ): Promise { + await queryRunner.query(` + INSERT INTO inventory_families( + code,name,level,legacy_type_code,information_labels,source_reference,is_active + ) VALUES ($1,$2,$3,NULL,'[]'::jsonb,$4,true) + ON CONFLICT (code) DO UPDATE SET + name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL, + information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference, + is_active=true,updated_at=CURRENT_TIMESTAMP + `,[familyCode,name,level,sourceReference]); + return this.id( + queryRunner, + `SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`, + [familyCode], + `inventory family ${familyCode}`, + ); + } + + private async parentRule(queryRunner: QueryRunner,childId:string,parentId:string):Promise { + await queryRunner.query(` + INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) + VALUES ($1::uuid,$2::uuid) + ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id + `,[childId,parentId]); + } + + private async mapFindings( + queryRunner: QueryRunner, + familyCode: string, + family: F5InstallationCatalogRow|F5SubinstallationCatalogRow, + universalFindings: string[], + itemIdByKey: Map, + ): Promise { + const familyId = await this.id( + queryRunner, + `SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`, + [familyCode], + `family ${familyCode}`, + ); + const mapped = new Set(); + for (const rawTitle of [...family.findings,...universalFindings]) { + const itemKey=findingKey(rawTitle); + if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue; + mapped.add(itemKey); + const itemId=itemIdByKey.get(itemKey); + if (!itemId) throw new Error(`F5 missing finding item ${rawTitle}`); + await queryRunner.query(` + INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) + VALUES ($1::uuid,$2::uuid) + ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING + `,[itemId,familyId]); + } + } + + private async installFamilySyncFunctions(queryRunner: QueryRunner):Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + DELETE FROM finding_catalog_asset_overrides + WHERE asset_id=NEW.id AND reason LIKE 'F% familia técnica:%'; + IF NEW.inventory_family_id IS NOT NULL THEN + INSERT INTO finding_catalog_asset_overrides( + asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by + ) + SELECT NEW.id,mapping.catalog_item_id,true, + 'F5 familia técnica: catálogo contextual automático', + NEW.created_by,NEW.updated_by + FROM finding_catalog_item_inventory_families mapping + WHERE mapping.inventory_family_id=NEW.inventory_family_id + ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET + is_enabled=true,reason='F5 familia técnica: catálogo contextual automático', + updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP; + END IF; + RETURN NEW; + END $$; + `); + await queryRunner.query(` + CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP='DELETE' THEN + DELETE FROM finding_catalog_asset_overrides override_record + USING assets asset + WHERE override_record.asset_id=asset.id + AND asset.inventory_family_id=OLD.inventory_family_id + AND override_record.catalog_item_id=OLD.catalog_item_id + AND override_record.reason LIKE 'F% familia técnica:%'; + RETURN OLD; + END IF; + INSERT INTO finding_catalog_asset_overrides( + asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by + ) + SELECT asset.id,NEW.catalog_item_id,true, + 'F5 familia técnica: catálogo contextual automático', + asset.created_by,asset.updated_by + FROM assets asset + WHERE asset.inventory_family_id=NEW.inventory_family_id + ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET + is_enabled=true,reason='F5 familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP; + RETURN NEW; + END $$; + `); + + await queryRunner.query(` + DELETE FROM finding_catalog_asset_overrides + WHERE reason LIKE 'F% familia técnica:%' + `); + await queryRunner.query(` + INSERT INTO finding_catalog_asset_overrides( + asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by + ) + SELECT asset.id,mapping.catalog_item_id,true,$1::text, + asset.created_by,asset.updated_by + FROM assets asset + JOIN finding_catalog_item_inventory_families mapping + ON mapping.inventory_family_id=asset.inventory_family_id + WHERE asset.inventory_family_id IS NOT NULL + ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET + is_enabled=true,reason=$1::text,updated_at=CURRENT_TIMESTAMP + `,[F5_AUTO_REASON]); + } + + private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + DELETE FROM finding_catalog_asset_overrides + WHERE asset_id=NEW.id AND reason LIKE 'F3.1 familia técnica:%'; + IF NEW.inventory_family_id IS NOT NULL THEN + INSERT INTO finding_catalog_asset_overrides( + asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by + ) + SELECT NEW.id,mapping.catalog_item_id,true, + 'F3.1 familia técnica: catálogo contextual automático', + NEW.created_by,NEW.updated_by + FROM finding_catalog_item_inventory_families mapping + WHERE mapping.inventory_family_id=NEW.inventory_family_id + ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET + is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático', + updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP; + END IF; + RETURN NEW; + END $$; + `); + await queryRunner.query(` + CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP='DELETE' THEN + DELETE FROM finding_catalog_asset_overrides override_record + USING assets asset + WHERE override_record.asset_id=asset.id + AND asset.inventory_family_id=OLD.inventory_family_id + AND override_record.catalog_item_id=OLD.catalog_item_id + AND override_record.reason LIKE 'F3.1 familia técnica:%'; + RETURN OLD; + END IF; + INSERT INTO finding_catalog_asset_overrides( + asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by + ) + SELECT asset.id,NEW.catalog_item_id,true, + 'F3.1 familia técnica: catálogo contextual automático', + asset.created_by,asset.updated_by + FROM assets asset + WHERE asset.inventory_family_id=NEW.inventory_family_id + ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET + is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático', + updated_at=CURRENT_TIMESTAMP; + RETURN NEW; + END $$; + `); + } + + private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise { + const value=await this.optionalId(queryRunner,sql,params); + if (!value) throw new Error(`F5 could not resolve ${label}`); + return value; + } + + private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise { + const rows=(await queryRunner.query(sql,params)) as IdRow[]; + return rows[0]?.id ?? null; + } +} diff --git a/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts b/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts index 1826bdd..5c65d26 100644 --- a/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts +++ b/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts @@ -1,41 +1,65 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto'; -import { FindingCatalogService } from './finding-catalog.service'; @Injectable() export class F3FindingCatalogResolverService { - constructor( - private readonly dataSource: DataSource, - private readonly legacyCatalog: FindingCatalogService, - ) {} + constructor(private readonly dataSource: DataSource) {} async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) { const [asset] = await this.dataSource.query(` SELECT asset.id,asset.code,asset.name, + type.code AS "typeCode", asset.inventory_family_id AS "familyId", - family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel" + family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel", + family.is_active AS "familyActive" FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id WHERE asset.id=$1::uuid `, [assetId]) as Array<{ id: string; code: string; name: string; + typeCode: string; familyId: string | null; familyCode: string | null; familyName: string | null; familyLevel: string | null; + familyActive: boolean | null; }>; if (!asset) { throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' }); } - if (!asset.familyId) return this.legacyCatalog.listApplicableForAsset(assetId, query); + + const other = { + enabled: true, + code: 'OTHER', + label: 'OTROS', + help: 'Usalo cuando el Hallazgo no exista en el catálogo aplicable. Se enviará una propuesta a revisión de oficina.', + }; + + // F5: no se mezclan catálogos históricos. Yacimiento no posee familia técnica + // en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS. + if (!asset.familyId || asset.familyActive !== true) { + return { + asset: { id: asset.id, code: asset.code, name: asset.name }, + inventoryFamily: null, + typeConfigured: false, + configurationReason: asset.typeCode.toLowerCase() === 'yacimiento' + ? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.' + : 'F5 · El elemento todavía no tiene una clasificación técnica activa.', + categories: [], + items: [], + other, + }; + } const filters = [ 'mapping.inventory_family_id=$1::uuid', 'item.is_active=true', 'category.is_active=true', + "lower(category.code)='f5model'", 'merge_record.source_item_id IS NULL', ]; const params: unknown[] = [asset.familyId]; @@ -83,15 +107,10 @@ export class F3FindingCatalogResolverService { level: asset.familyLevel, }, typeConfigured: true, - configurationReason: `F3.1 · Catálogo del Excel asociado a ${asset.familyName ?? 'la familia técnica'}`, + configurationReason: `F5 · Catálogo final_modelov2.xlsx asociado a ${asset.familyName ?? 'la clasificación técnica'}`, categories, items, - other: { - enabled: true, - code: 'OTHER', - label: 'OTROS', - help: 'Usalo cuando el hallazgo no exista en la familia técnica. Se enviará una propuesta a revisión de oficina.', - }, + other, }; } } diff --git a/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts b/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts index 1456bd6..bd77ca9 100644 --- a/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts +++ b/api-v3/src/inspection-visits/f3-field-inventory-structure.service.ts @@ -122,7 +122,20 @@ export class F3FieldInventoryStructureService { asset: { id: string; code: string; name: string }; [key: string]: unknown; }; - if (!family) return created; + + // Área/Yacimiento remain structural context. A concrete Installation/Subinstallation + // created in field is a real Inventory instance from the moment it is registered. + await this.dataSource.query(` + UPDATE assets + SET is_inventory_instance=$3::boolean,updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP + WHERE id=$1::uuid + `, [ + created.asset.id, + principal.userId, + typeCode === 'instalacion' || typeCode === 'subinstalacion', + ]); + + if (!family) return this.fieldInventory.detail(visitId, created.asset.id, principal); await this.dataSource.transaction(async (manager) => { const [current] = await manager.query(` diff --git a/api-v3/src/inspection-visits/field-inventory.service.ts b/api-v3/src/inspection-visits/field-inventory.service.ts index a2e41b7..04e3066 100644 --- a/api-v3/src/inspection-visits/field-inventory.service.ts +++ b/api-v3/src/inspection-visits/field-inventory.service.ts @@ -29,6 +29,7 @@ interface MobileVisitContext { companyCode: string; companyName: string; assigned: boolean; + operatorRelationValid: boolean; } interface FieldDiscoveryCreated { @@ -79,11 +80,23 @@ export class FieldInventoryService { const context = await this.requireVisitContext(visitId, principal, false); if (query.parentId) await this.requireParentInContext(query.parentId, context); - const args: unknown[] = [context.areaId, context.companyId, visitId]; + // Empresa is visit context, never physical ownership. Search by Area ancestry. + const args: unknown[] = [context.areaId, visitId]; const conditions = [ - 'asset.operational_area_id = $1::uuid', - 'asset.operator_company_id = $2::uuid', "asset.information_status <> 'INACTIVE'", + `( + asset.operational_area_id = $1::uuid + OR EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=asset.parent_id + UNION ALL + SELECT parent.id,parent.parent_id + FROM assets parent JOIN ancestors child ON parent.id=child.parent_id + ) + SELECT 1 FROM ancestors WHERE id=$1::uuid LIMIT 1 + ) + )`, + "(asset.is_inventory_instance=true OR lower(type.code)='yacimiento')", ]; if (query.search?.trim()) { @@ -129,13 +142,13 @@ export class FieldInventoryService { ) END AS parent, EXISTS ( SELECT 1 FROM inspection_visit_assets link - WHERE link.visit_id = $3::uuid + WHERE link.visit_id = $2::uuid AND link.asset_id = asset.id AND link.included = true ) AS "selectedInInspection", EXISTS ( SELECT 1 FROM asset_field_discoveries discovery - WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id + WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id ) AS "captureRequired", EXISTS ( SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id @@ -143,26 +156,26 @@ export class FieldInventoryService { ( SELECT COUNT(*)::integer FROM asset_field_capture_events capture - WHERE capture.visit_id = $3::uuid + WHERE capture.visit_id = $2::uuid AND capture.asset_id = asset.id AND capture.event_type = 'PHOTO' ) AS "fieldPhotoCount", ( NOT EXISTS ( SELECT 1 FROM asset_field_discoveries discovery - WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id + WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id ) OR ( EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id) AND EXISTS ( SELECT 1 FROM asset_field_capture_events capture - WHERE capture.visit_id = $3::uuid + WHERE capture.visit_id = $2::uuid AND capture.asset_id = asset.id AND capture.event_type = 'CREATED' ) AND EXISTS ( SELECT 1 FROM asset_field_capture_events capture - WHERE capture.visit_id = $3::uuid + WHERE capture.visit_id = $2::uuid AND capture.asset_id = asset.id AND capture.event_type = 'PHOTO' ) @@ -279,6 +292,7 @@ export class FieldInventoryService { typeId: dto.typeId, parentId, operationalAreaId: context.areaId, + // Compatibility-only creation snapshot. Membership never depends on it. operatorCompanyId: context.companyId, description: dto.description ?? null, discoveryNotes: dto.discoveryNotes ?? null, @@ -413,7 +427,19 @@ export class FieldInventoryService { AND member.user_id = $2::uuid AND member.included = true ) - ) AS assigned + ) AS assigned, + EXISTS ( + SELECT 1 + FROM area_company_relations relation + WHERE relation.area_id=visit.operational_area_id + AND relation.company_id=visit.operator_company_id + AND relation.relation_role='OPERATOR' + AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at) + AND ( + relation.valid_until IS NULL + OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at) + ) + ) AS "operatorRelationValid" FROM inspection_visits visit LEFT JOIN assets area ON area.id = visit.operational_area_id LEFT JOIN assets company ON company.id = visit.operator_company_id @@ -429,6 +455,12 @@ export class FieldInventoryService { message: 'La inspección no tiene Área y Operadora definidas', }); } + if (!context.operatorRelationValid) { + throw new ConflictException({ + code: 'FIELD_INVENTORY_OPERATOR_RELATION_INVALID', + message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección', + }); + } if (!context.assigned) { throw new ConflictException({ code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED', @@ -469,18 +501,27 @@ export class FieldInventoryService { asset.code, asset.name, asset.asset_type_id AS "typeId", - asset.operational_area_id AS "areaId", - asset.operator_company_id AS "companyId" + ( + asset.id=$2::uuid + OR asset.operational_area_id=$2::uuid + OR EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=asset.parent_id + UNION ALL + SELECT parent.id,parent.parent_id + FROM assets parent JOIN ancestors child ON parent.id=child.parent_id + ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 + ) + ) AS "insideArea" FROM assets asset WHERE asset.id = $1::uuid AND asset.information_status <> 'INACTIVE' - `, [parentId])) as Array<{ + `, [parentId, context.areaId])) as Array<{ id: string; code: string; name: string; typeId: string; - areaId: string | null; - companyId: string | null; + insideArea: boolean; }>; if (!parent) { @@ -489,13 +530,10 @@ export class FieldInventoryService { message: 'La ubicación padre no existe', }); } - if ( - parent.id !== context.areaId - && (parent.areaId !== context.areaId || parent.companyId !== context.companyId) - ) { + if (!parent.insideArea) { throw new BadRequestException({ code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT', - message: 'La ubicación padre no pertenece al Área y Operadora de la inspección', + message: 'La ubicación padre no pertenece al Área de la inspección', }); } return parent; @@ -503,16 +541,28 @@ export class FieldInventoryService { private async requireAssetInContext(assetId: string, context: MobileVisitContext) { const [asset] = (await this.dataSource.query(` - SELECT id, - operational_area_id AS "areaId", - operator_company_id AS "companyId" - FROM assets - WHERE id = $1::uuid - AND information_status <> 'INACTIVE' - `, [assetId])) as Array<{ + SELECT + asset.id, + (asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget", + ( + asset.operational_area_id=$2::uuid + OR EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=asset.parent_id + UNION ALL + SELECT parent.id,parent.parent_id + FROM assets parent JOIN ancestors child ON parent.id=child.parent_id + ) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1 + ) + ) AS "insideArea" + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE asset.id = $1::uuid + AND asset.information_status <> 'INACTIVE' + `, [assetId, context.areaId])) as Array<{ id: string; - areaId: string | null; - companyId: string | null; + eligibleTarget: boolean; + insideArea: boolean; }>; if (!asset) { @@ -521,10 +571,16 @@ export class FieldInventoryService { message: 'Registro de Inventario no encontrado', }); } - if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) { + if (!asset.insideArea) { throw new BadRequestException({ code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT', - message: 'El registro no pertenece al Área y Operadora de esta inspección', + message: 'El registro no pertenece al Área de esta inspección', + }); + } + if (!asset.eligibleTarget) { + throw new BadRequestException({ + code: 'FIELD_INVENTORY_NOT_OPERATIONAL_TARGET', + message: 'El registro no es un Yacimiento ni una instancia real de Inventario', }); } return asset; diff --git a/api-v3/src/reference-data/f5-authoritative-inventory-source.ts b/api-v3/src/reference-data/f5-authoritative-inventory-source.ts new file mode 100644 index 0000000..f396582 --- /dev/null +++ b/api-v3/src/reference-data/f5-authoritative-inventory-source.ts @@ -0,0 +1,64 @@ +import { gunzipSync } from 'node:zlib'; + +/** + * F5 · Fuente maestra de Configuración de Inventarios. + * + * ÚNICAS fuentes autorizadas por DH: + * - Tablas de yacimiento y areas.xlsx · cr26e_tabla1 + * SHA-256: 8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78 + * - final_modelov2.xlsx · Hoja1 + * SHA-256: c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155 + * + * No deriva jerarquías ni hallazgos de archivos históricos. + * Área → Yacimiento → Instalación → Subinstalación. + * Empresa es un maestro independiente y se vincula temporalmente al Área. + */ +export interface F5AreaSourceRow { + sourceRow: number; + yacimiento: string; + area: string; + departamento: string; + tipoConcesion: string; + empresaOperadora: string; +} + +export interface F5InstallationCatalogRow { + name: string; + sourceStartRow: number; + sourceEndRow: number; + findings: string[]; +} + +export interface F5SubinstallationCatalogRow { + installation: string; + name: string; + sourceStartRow: number; + sourceEndRow: number; + reference: string | null; + findings: string[]; +} + +export interface F5InventoryAuthoritativeSource { + areaSource: { + file: string; + sheet: string; + sha256: string; + rows: F5AreaSourceRow[]; + }; + catalogSource: { + file: string; + sheet: string; + sha256: string; + installations: F5InstallationCatalogRow[]; + subinstallations: F5SubinstallationCatalogRow[]; + universalFindings: string[]; + }; +} + +const SNAPSHOT_GZIP_BASE64 = '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'; + +export function loadF5InventoryAuthoritativeSource(): F5InventoryAuthoritativeSource { + return JSON.parse( + gunzipSync(Buffer.from(SNAPSHOT_GZIP_BASE64, 'base64')).toString('utf8'), + ) as F5InventoryAuthoritativeSource; +} diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 99bb064..d406061 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.25.0-1'; -export const API_PHASE = 'F4'; +export const API_VERSION = '0.26.0-1'; +export const API_PHASE = 'F5'; diff --git a/api-v3/test/unit/asset-navigation-d5-3-11-1.test.ts b/api-v3/test/unit/asset-navigation-d5-3-11-1.test.ts index 51fcb93..a2ed68d 100644 --- a/api-v3/test/unit/asset-navigation-d5-3-11-1.test.ts +++ b/api-v3/test/unit/asset-navigation-d5-3-11-1.test.ts @@ -7,13 +7,15 @@ async function source(relativePath: string): Promise { return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8'); } -test('D5.3.11.1 exposes imported operational assignments in Maestro navigation without creating legal relations', async () => { +test('D5.3.11.1/F5 navigates Area ↔ Empresa from temporal operator relations instead of Inventory ownership', async () => { const service = await source('asset-master/asset-operational-relations.service.ts'); - assert.match(service, /SELECT asset\.operator_company_id AS company_id/); - assert.match(service, /WHERE asset\.operational_area_id = \$1/); - assert.match(service, /SELECT asset\.operational_area_id AS area_id/); - assert.match(service, /WHERE asset\.operator_company_id = \$1/); - assert.match(service, /UNION/); + assert.match(service, /FROM area_company_relations relation/); + assert.match(service, /WHERE relation\.area_id = \$1/); + assert.match(service, /WHERE relation\.company_id = \$1/); + assert.match(service, /relation\.relation_role = 'OPERATOR'/); + assert.match(service, /relation\.valid_until IS NULL/); + assert.doesNotMatch(service, /SELECT asset\.operator_company_id AS company_id/); + assert.doesNotMatch(service, /SELECT asset\.operational_area_id AS area_id/); assert.doesNotMatch(service, /INSERT INTO area_company_relations[\s\S]*listAreasForCompany/); }); diff --git a/api-v3/test/unit/asset-operational-relations-integrity.test.ts b/api-v3/test/unit/asset-operational-relations-integrity.test.ts index 214aad4..9e66d53 100644 --- a/api-v3/test/unit/asset-operational-relations-integrity.test.ts +++ b/api-v3/test/unit/asset-operational-relations-integrity.test.ts @@ -33,11 +33,13 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry', assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/); }); -test('D5.3 relation lifecycle is historical and audited', async () => { +test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => { const service = await source('asset-master/asset-operational-relations.service.ts'); assert.match(service, /INSERT INTO area_company_relations/); assert.match(service, /valid_until = CURRENT_TIMESTAMP/); - assert.match(service, /AREA_COMPANY_RELATION_IN_USE/); + assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/); + assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/); + assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/); }); diff --git a/api-v3/test/unit/dashboard-service.test.ts b/api-v3/test/unit/dashboard-service.test.ts index f030d9b..f206087 100644 --- a/api-v3/test/unit/dashboard-service.test.ts +++ b/api-v3/test/unit/dashboard-service.test.ts @@ -2,27 +2,28 @@ import { strict as assert } from 'node:assert'; import test from 'node:test'; import { DashboardService } from '../../src/dashboard/dashboard.service'; -test('DashboardService returns F4 operational counts and recent activity consistently', async () => { +test('DashboardService returns F5 operational counts and inspector activity consistently', async () => { const recent = [{ id: 'event-id', - occurredAt: new Date('2026-08-13T12:00:00.000Z'), - actorUsername: 'admin', - action: 'AUTH_LOGIN_SUCCESS', - entityType: 'auth_session', - entityId: 'session-id', + occurredAt: new Date('2026-09-08T12:00:00.000Z'), + actorUsername: 'inspector1', + action: 'INSPECTION_STARTED', + entityType: 'inspection_visit', + entityId: 'visit-id', }]; const manager = { query: async (sql: string) => { if (sql.includes('COUNT(*) FROM users')) { return [{ activeUsers: '4', inactiveUsers: '2', activeSessions: '3', - totalAssets: '12', assetsNeedValidation: '4', assetsWithoutGeometry: '6', plannedInspections: '2', - openFindings: '8', findingsWithoutControlDate: '5', overdueControls: '2', controlsNext30Days: '3', + totalAssets: '0', assetsNeedValidation: '0', assetsWithoutGeometry: '0', plannedInspections: '2', + openFindings: '8', actsInFollowUp: '6', findingsWithoutControlDate: '5', overdueControls: '2', controlsNext30Days: '3', reportsWorking: '1', reportsOfficialized: '2', sealedActsWithoutReport: '4', }]; } - if (sql.includes('FROM inspection_findings finding')) return []; - return recent; + if (sql.includes('FROM audit_events event')) return recent; + if (sql.includes('next_control_on AS "nextControlOn"')) return []; + throw new Error(`Unexpected DashboardService query in test: ${sql}`); }, }; const dataSource = { @@ -31,14 +32,15 @@ test('DashboardService returns F4 operational counts and recent activity consist const result = await new DashboardService(dataSource as never).summary(); assert.deepEqual(result.counts, { - totalAssets: 12, - assetsNeedValidation: 4, - assetsWithoutGeometry: 6, + totalAssets: 0, + assetsNeedValidation: 0, + assetsWithoutGeometry: 0, plannedInspections: 2, activeUsers: 4, inactiveUsers: 2, activeSessions: 3, openFindings: 8, + actsInFollowUp: 6, findingsWithoutControlDate: 5, overdueControls: 2, controlsNext30Days: 3, @@ -46,7 +48,8 @@ test('DashboardService returns F4 operational counts and recent activity consist reportsOfficialized: 2, sealedActsWithoutReport: 4, }); - assert.equal(result.recentAudit, recent); + assert.deepEqual(result.recentInspectorActivity, recent); + assert.equal('recentAudit' in result, false); assert.deepEqual(result.upcomingControls, []); assert.equal(typeof result.generatedAt, 'string'); }); diff --git a/api-v3/test/unit/f3-1-chronological-merge.test.ts b/api-v3/test/unit/f3-1-chronological-merge.test.ts index 8f9b96c..37c364f 100644 --- a/api-v3/test/unit/f3-1-chronological-merge.test.ts +++ b/api-v3/test/unit/f3-1-chronological-merge.test.ts @@ -19,12 +19,14 @@ test('F3.1 registra cada fusión como evento append-only con snapshots', () => { assert.match(migration, /append-only/); }); -test('F3.1 sólo permite fusionar Instalaciones o Subinstalaciones compatibles', () => { +test('F3.1/F5 sólo permite fusionar Instalaciones o Subinstalaciones compatibles dentro de la misma Área física', () => { assert.match(service, /MERGEABLE_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/); assert.match(service, /INVENTORY_MERGE_TYPE_INVALID/); - assert.match(service, /INVENTORY_MERGE_CONTEXT_MISMATCH/); + assert.match(service, /resolvePhysicalAreaId/); + assert.match(service, /INVENTORY_MERGE_AREA_MISMATCH/); assert.match(service, /INVENTORY_MERGE_PARENT_MISMATCH/); assert.match(service, /INVENTORY_MERGE_CHILD_FAMILY_CONFLICT/); + assert.doesNotMatch(service, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/); }); test('F3.1 preserva referencias históricas y sólo reubica hijos actuales', () => { diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index 87e01f5..048c707 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { API_PHASE, API_VERSION } from '../../src/version'; -test('F4 health metadata reports the deployed phase without changing the release version', () => { - assert.equal(API_PHASE, 'F4'); - assert.equal(API_VERSION, '0.25.0-1'); +test('health metadata reports the current F5 release', () => { + assert.equal(API_PHASE, 'F5'); + assert.equal(API_VERSION, '0.26.0-1'); }); diff --git a/api-v3/test/unit/f5-inventory-authoritative-contract.test.ts b/api-v3/test/unit/f5-inventory-authoritative-contract.test.ts new file mode 100644 index 0000000..1863a6c --- /dev/null +++ b/api-v3/test/unit/f5-inventory-authoritative-contract.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import { loadF5InventoryAuthoritativeSource } from '../../src/reference-data/f5-authoritative-inventory-source'; + +function key(value: string): string { + return value.normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +test('F5 territory preload is exactly the authorized Tablas de yacimiento y areas workbook', () => { + const source = loadF5InventoryAuthoritativeSource().areaSource; + assert.equal(source.file, 'Tablas de yacimiento y areas.xlsx'); + assert.equal(source.sheet, 'cr26e_tabla1'); + assert.equal(source.sha256, '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78'); + assert.equal(source.rows.length, 230); + + const areas = new Set(source.rows.map((row) => key(row.area))); + const literalYacimientoNames = new Set(source.rows.map((row) => row.yacimiento.trim())); + const yacimientos = new Set(source.rows.map((row) => key(row.yacimiento))); + const areaYacimiento = new Set(source.rows.map((row) => `${key(row.area)}|${key(row.yacimiento)}`)); + const departments = new Set(source.rows.map((row) => key(row.departamento))); + const concessionTypes = new Set(source.rows.map((row) => key(row.tipoConcesion))); + const operators = new Set(source.rows.map((row) => key(row.empresaOperadora))); + + assert.equal(areas.size, 64); + // The authorized workbook contains 220 literal Yacimiento names. The F5 key + // intentionally folds accents/punctuation/case, so one pair of literal names + // collides into the same normalized key. Yacimiento identity is Area+Yacimiento, + // never a globally-unique normalized name. + assert.equal(literalYacimientoNames.size, 220); + assert.equal(yacimientos.size, 219); + assert.equal(areaYacimiento.size, 230); + assert.equal(departments.size, 7); + assert.equal(concessionTypes.size, 2); + assert.equal(operators.size, 13); + + const literalNamesByKey = new Map>(); + for (const row of source.rows) { + const normalized = key(row.yacimiento); + const literals = literalNamesByKey.get(normalized) ?? new Set(); + literals.add(row.yacimiento.trim()); + literalNamesByKey.set(normalized, literals); + } + assert.equal([...literalNamesByKey.values()].filter((items) => items.size > 1).length, 1); + + const literalAreasByYacimiento = new Map>(); + const areasByYacimiento = new Map>(); + for (const row of source.rows) { + const literal = row.yacimiento.trim(); + const literalMemberships = literalAreasByYacimiento.get(literal) ?? new Set(); + literalMemberships.add(key(row.area)); + literalAreasByYacimiento.set(literal, literalMemberships); + + const normalized = key(row.yacimiento); + const memberships = areasByYacimiento.get(normalized) ?? new Set(); + memberships.add(key(row.area)); + areasByYacimiento.set(normalized, memberships); + } + const literalMembershipSurplus = [...literalAreasByYacimiento.values()] + .reduce((total, memberships) => total + Math.max(0, memberships.size - 1), 0); + const normalizedMembershipSurplus = [...areasByYacimiento.values()] + .reduce((total, memberships) => total + Math.max(0, memberships.size - 1), 0); + assert.equal(literalMembershipSurplus, 10); + assert.equal(normalizedMembershipSurplus, 11); + + for (const area of areas) { + const rows = source.rows.filter((row) => key(row.area) === area); + assert.equal(new Set(rows.map((row) => key(row.departamento))).size, 1, `${area}: departamento`); + assert.equal(new Set(rows.map((row) => key(row.tipoConcesion))).size, 1, `${area}: concesión`); + assert.equal(new Set(rows.map((row) => key(row.empresaOperadora))).size, 1, `${area}: operadora`); + } +}); + +test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM references', () => { + const source = loadF5InventoryAuthoritativeSource().catalogSource; + assert.equal(source.file, 'final_modelov2.xlsx'); + assert.equal(source.sheet, 'Hoja1'); + assert.equal(source.sha256, 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155'); + assert.equal(source.installations.length, 14); + assert.equal(source.subinstallations.length, 109); + + const installationKeys = new Set(source.installations.map((item) => key(item.name))); + assert.equal(installationKeys.size, 14); + for (const subinstallation of source.subinstallations) { + assert.ok(installationKeys.has(key(subinstallation.installation)), `${subinstallation.name} parent`); + assert.ok(subinstallation.findings.every((finding) => !/^idem\b/i.test(finding.trim())), `${subinstallation.name} has unresolved IDEM`); + } + for (const installation of source.installations) { + assert.ok(installation.findings.every((finding) => !/^idem\b/i.test(finding.trim())), `${installation.name} has unresolved IDEM`); + } + + const allFindings = new Map(); + for (const finding of source.universalFindings) allFindings.set(key(finding), finding); + for (const installation of source.installations) { + for (const finding of installation.findings) allFindings.set(key(finding), finding); + } + for (const subinstallation of source.subinstallations) { + for (const finding of subinstallation.findings) allFindings.set(key(finding), finding); + } + assert.equal(allFindings.size, 177); + assert.deepEqual( + new Set(source.universalFindings.map(key)), + new Set([ + 'ORDEN Y LIMPIEZA', + 'CARTELERIA PREVENTIVA / INFORMATIVA', + 'EXTINTORES', + ].map(key)), + ); +}); + +test('F5 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => { + const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8'); + const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8'); + const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8'); + const catalogMigration = readFileSync('src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts', 'utf8'); + + assert.match(mergeSource, /resolvePhysicalAreaId/); + assert.match(mergeSource, /documentInvariantsBefore/); + assert.match(mergeSource, /locked_sha256/); + assert.match(mergeSource, /closure_sha256/); + assert.match(mergeSource, /frozen_sha256/); + assert.match(mergeSource, /gedo_pdf_sha256/); + assert.match(mergeSource, /historicalReferencesRewritten:\s*false/); + assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/); + assert.doesNotMatch(mergeSource, /misma Área y Operadora/); + + assert.match(fieldSource, /Área de esta inspección/); + assert.match(fieldSource, /area_company_relations/); + assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/); + + assert.match(contextMigration, /public async up[\s\S]*installAreaOwnedGuard/); + assert.match(contextMigration, /public async down[\s\S]*installLegacyPairedGuard/); + assert.match(contextMigration, /La Empresa se cambia en la relación temporal del Área/); + + assert.doesNotMatch(catalogMigration, /unaccent\s*\(/i); + assert.doesNotMatch(catalogMigration, /source_reference\s+IS\s+NULL/i); + assert.match(catalogMigration, /F5_SOURCE_FAMILY_COUNT\s*=\s*123/); +}); diff --git a/web-v2/package.json b/web-v2/package.json index fda5257..ade9c9f 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.20.0-4", + "version": "0.21.0-1", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/app/App.tsx b/web-v2/src/app/App.tsx index 8b62df0..a67237a 100644 --- a/web-v2/src/app/App.tsx +++ b/web-v2/src/app/App.tsx @@ -18,7 +18,6 @@ import { AssetEditorPage } from '../pages/AssetEditorPage'; import { InventoryCreatePage } from '../pages/InventoryCreatePage'; import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage'; import { AssetTypesPage } from '../pages/AssetTypesPage'; -import { InventoryFunctionsPage } from '../pages/InventoryFunctionsPage'; import { HistoryPage } from '../pages/HistoryPage'; import { TemporalAssetsPage } from '../pages/TemporalAssetsPage'; import { InspectionVisitsPage } from '../pages/InspectionVisitsPage'; @@ -27,7 +26,6 @@ import { InspectionActEditorPage } from '../pages/InspectionActEditorPage'; import { FindingCatalogPage } from '../pages/FindingCatalogPage'; import { FindingDetailPage } from '../pages/FindingDetailPage'; import { FindingsPage } from '../pages/FindingsPage'; -import { AssetImportsPage } from '../pages/AssetImportsPage'; import { ActsPage } from '../pages/ActsPage'; import { ReportsPage } from '../pages/ReportsPage'; import { ReportDetailPage } from '../pages/ReportDetailPage'; @@ -55,7 +53,6 @@ export function App() { } /> }>} /> - }>} /> }>} />} /> } /> }> @@ -82,7 +79,6 @@ export function App() { }>} /> }>} /> }>} /> - }>} /> }>} /> }>} /> } /> diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index f8a9534..cf00036 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ export const APP_VERSION = '0.21.0-1'; -export const APP_PHASE = 'Fase F3.1 · Inventario estructural y catálogo contextual'; +export const APP_PHASE = 'F5 · Inventario operativo y catálogo autorizado'; diff --git a/web-v2/src/f5.css b/web-v2/src/f5.css new file mode 100644 index 0000000..8e7530e --- /dev/null +++ b/web-v2/src/f5.css @@ -0,0 +1,90 @@ +/* F5 · ajustes visuales acotados a los flujos saneados. */ + +.status-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 18px; + padding: 7px; + border: 1px solid var(--border, #e2e8f0); + border-radius: 14px; + background: var(--surface, #fff); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04); +} + +.status-tabs button { + appearance: none; + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 38px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + color: var(--text-muted, #64748b); + font: inherit; + font-size: 13px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: background .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease; +} + +.status-tabs button:hover { + background: #f8fafc; + border-color: #e2e8f0; + color: #0f172a; +} + +.status-tabs button.active { + background: #eef4ff; + border-color: #bfd2fb; + color: #173d7a; + box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.04); +} + +.status-tabs button small { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 6px; + border-radius: 999px; + background: #edf2f7; + color: #475569; + font-size: 11px; + font-weight: 800; +} + +.status-tabs button.active small { + background: #d7e5ff; + color: #173d7a; +} + +.asset-browser-item-status > strong { + display: block; + font-size: 18px; + line-height: 1; + text-align: right; +} + +.asset-browser-item-status > small { + display: block; + margin-top: 5px; +} + +@media (max-width: 720px) { + .status-tabs { + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: thin; + padding-bottom: 9px; + } + + .status-tabs button { + flex: 0 0 auto; + white-space: nowrap; + } +} diff --git a/web-v2/src/features/assets/AssetHierarchyView.tsx b/web-v2/src/features/assets/AssetHierarchyView.tsx index 70ae762..c5f092c 100644 --- a/web-v2/src/features/assets/AssetHierarchyView.tsx +++ b/web-v2/src/features/assets/AssetHierarchyView.tsx @@ -1,108 +1,87 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useSearchParams } from 'react-router'; import { useAuth } from '../../auth/AuthContext'; import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Icon } from '../../components/Icon'; +import { getAssetLineage } from '../../lib/api'; +import type { AssetLineageItem } from '../../lib/api'; import { - getAsset, - getAssetLineage, - listAreasForCompany, - listCompaniesForArea, - listAssetTreeChildren, - listOperationalAreas, - listOperationalCompanies, -} from '../../lib/api'; -import type { AssetDetail, AssetLineageItem, AssetListItem, OperationalAssetSummary } from '../../lib/api'; + listInventoryAreas, + listInventoryChildren, +} from '../../lib/inventoryBrowserApi'; +import type { + InventoryBrowserArea, + InventoryBrowserItem, + InventoryQuery, +} from '../../lib/inventoryBrowserApi'; import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation'; -type TreeFilters = Omit[0]>, 'parentId' | 'limit'>; -type NavigationSection = 'companies' | 'territory'; -type ChildGroupKey = 'fields' | 'installations' | 'wells' | 'equipment'; - -const INSTALLATION_CODES = new Set([ - 'estructura_local', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion', 'subestacion', - 'zona_bombas', 'sistema_drenaje', 'sistema_electrico_iluminacion', 'sistema_defensa_incendios', - 'pileta_api', 'cargadero_descargadero', -]); - -const GROUP_ORDER: ChildGroupKey[] = ['fields', 'installations', 'wells', 'equipment']; -const GROUP_LABELS: Record = { - fields: { title: 'Yacimientos', description: 'Unidades territoriales u operativas dentro del Área.' }, - installations: { title: 'Instalaciones y estructura', description: 'Plantas, baterías, estaciones, locaciones y niveles estructurales.' }, - wells: { title: 'Pozos', description: 'Pozos identificados dentro del contexto seleccionado.' }, - equipment: { title: 'Equipos y otros elementos', description: 'Equipos técnicos y demás elementos registrados en el inventario.' }, -}; - -function childGroup(item: AssetListItem): ChildGroupKey { - const code = item.type.code.toLowerCase(); - if (code === 'yacimiento') return 'fields'; - if (INSTALLATION_CODES.has(code)) return 'installations'; - if (code === 'pozo') return 'wells'; - return 'equipment'; -} - -function normalizeSearch(value: string | undefined) { - return value?.trim().toLocaleLowerCase('es-AR') ?? ''; -} - -function matchesSearch(item: { name: string; code: string; commonName?: string | null }, search?: string) { - if (!search) return true; - const term = normalizeSearch(search); - return item.name.toLocaleLowerCase('es-AR').includes(term) || item.code.toLocaleLowerCase('es-AR').includes(term) || Boolean(item.commonName?.toLocaleLowerCase('es-AR').includes(term)); -} - -function navigationHref(base: URLSearchParams, section: NavigationSection, options: { companyId?: string; parentId?: string } = {}) { +function navigationHref(base: URLSearchParams, parentId?: string) { const params = new URLSearchParams(base); params.delete('view'); params.delete('page'); - params.delete('operationalAreaId'); - params.delete('operatorCompanyId'); - params.set('section', section); - options.companyId ? params.set('companyId', options.companyId) : params.delete('companyId'); - options.parentId ? params.set('parentId', options.parentId) : params.delete('parentId'); - return `/inventarios?${params}`; + params.delete('section'); + params.delete('companyId'); + parentId ? params.set('parentId', parentId) : params.delete('parentId'); + return `/inventarios${params.size ? `?${params}` : ''}`; } -function AssetCard({ item, href }: { item: AssetListItem; href: string }) { +function AreaCard({ area, href }: { area: InventoryBrowserArea; href: string }) { return - + - {item.name} - {item.code} · {item.type.name}{item.commonName ? ` · ${item.commonName}` : ''} + {area.name} + + {area.code} · {area.yacimientoCount} yacimiento{area.yacimientoCount === 1 ? '' : 's'} + {area.currentOperator ? ` · Operadora vigente: ${area.currentOperator.name}` : ' · Sin operadora vigente'} + - {assetStatusLabel(item.informationStatus)} - {assetOperationalStatusLabel(item.operationalStatus)} + {area.inventoryCount} + instancia{area.inventoryCount === 1 ? '' : 's'} real{area.inventoryCount === 1 ? '' : 'es'} ; } -function SummaryCard({ item, href, subtitle }: { item: OperationalAssetSummary; href: string; subtitle: string }) { +function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) { + const structural = !item.isInventoryInstance; return - + {item.name} - {item.code} · {subtitle}{item.commonName ? ` · ${item.commonName}` : ''} + + {item.code} · {item.type.name} + {item.inventoryFamily ? ` · ${item.inventoryFamily.name}` : ''} + {item.commonName ? ` · ${item.commonName}` : ''} + + + + {structural + ? <>Contexto{item.childrenCount} nivel{item.childrenCount === 1 ? '' : 'es'} inferior{item.childrenCount === 1 ? '' : 'es'} + : <>{assetStatusLabel(item.informationStatus)}{assetOperationalStatusLabel(item.operationalStatus)}} ; } -export function AssetHierarchyView({ filters }: { filters: TreeFilters }) { +function nextLevelLabel(typeCode: string | undefined) { + switch (typeCode?.toLowerCase()) { + case 'area': return 'Yacimientos'; + case 'yacimiento': return 'Instalaciones'; + case 'instalacion': return 'Subinstalaciones'; + default: return 'Niveles inferiores'; + } +} + +export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) { const { hasPermission } = useAuth(); const canCreate = hasPermission('assets.create'); const [searchParams] = useSearchParams(); - const rawSection = searchParams.get('section'); - const section: NavigationSection | null = rawSection === 'companies' || rawSection === 'territory' ? rawSection : null; - const companyId = searchParams.get('companyId') ?? ''; const parentId = searchParams.get('parentId') ?? ''; - - const [companies, setCompanies] = useState([]); - const [areas, setAreas] = useState([]); - const [company, setCompany] = useState(null); + const [areas, setAreas] = useState([]); + const [children, setChildren] = useState([]); const [lineage, setLineage] = useState([]); - const [children, setChildren] = useState([]); const [hasMore, setHasMore] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -111,146 +90,110 @@ export function AssetHierarchyView({ filters }: { filters: TreeFilters }) { let active = true; setLoading(true); setError(''); - setCompanies([]); setAreas([]); - setCompany(null); - setLineage([]); setChildren([]); + setLineage([]); setHasMore(false); const run = async () => { - if ((!section || section === 'companies') && !companyId && !parentId) { - const loaded = filters.operationalAreaId ? await listCompaniesForArea(filters.operationalAreaId) : await listOperationalCompanies(); - if (active) setCompanies(loaded.filter((item) => (!filters.operatorCompanyId || item.id === filters.operatorCompanyId) && matchesSearch(item, filters.search))); + if (!parentId) { + const response = await listInventoryAreas({ + search: filters.search, + operationalAreaId: filters.operationalAreaId, + operatorCompanyId: filters.operatorCompanyId, + }); + if (active) setAreas(response.data); return; } - if (section === 'companies' && companyId && !parentId) { - const [loadedCompany, loadedAreas] = await Promise.all([getAsset(companyId), listAreasForCompany(companyId)]); - if (!active) return; - setCompany(loadedCompany); - setAreas(loadedAreas.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search))); - return; - } - - if (section === 'territory' && !parentId) { - const loaded = await listOperationalAreas(); - if (active) setAreas(loaded.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search))); - return; - } - - if (parentId) { - const [loadedLineage, childResponse, loadedCompany] = await Promise.all([ - getAssetLineage(parentId), - listAssetTreeChildren({ - ...filters, - operatorCompanyId: companyId || filters.operatorCompanyId, - parentId, - limit: 200, - }), - companyId ? getAsset(companyId) : Promise.resolve(null), - ]); - if (!active) return; - setLineage(loadedLineage); - setChildren(childResponse.data); - setHasMore(childResponse.meta.hasMore); - setCompany(loadedCompany); - } + const [loadedLineage, response] = await Promise.all([ + getAssetLineage(parentId), + listInventoryChildren(parentId, { search: filters.search }), + ]); + if (!active) return; + setLineage(loadedLineage.filter((item) => ['area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase()))); + setChildren(response.data); + setHasMore(response.meta.hasMore); }; - run().catch((requestError) => active && setError(errorMessage(requestError))).finally(() => active && setLoading(false)); + run() + .catch((requestError) => active && setError(errorMessage(requestError))) + .finally(() => active && setLoading(false)); return () => { active = false; }; - }, [section, companyId, parentId, JSON.stringify(filters)]); - - const groupedChildren = useMemo(() => { - const groups = new Map(); - children.forEach((item) => { - const key = childGroup(item); - groups.set(key, [...(groups.get(key) ?? []), item]); - }); - return GROUP_ORDER.map((key) => ({ key, items: groups.get(key) ?? [] })).filter((group) => group.items.length > 0); - }, [children]); - - const current = lineage.at(-1) ?? null; - const activeSection: NavigationSection = section ?? 'companies'; + }, [parentId, filters.search, filters.operationalAreaId, filters.operatorCompanyId]); if (loading) return ; - if (!section && !companyId && !parentId) { + if (!parentId) { + const realTotal = areas.reduce((sum, area) => sum + Number(area.inventoryCount ?? 0), 0); return
{error && {error}}
-
INVENTARIOS POR EMPRESA

Elegí una empresa

Cada empresa tiene su propio inventario. Ingresá para recorrer Áreas, Yacimientos, instalaciones y equipos.

-
Vista territorial{companies.length}
+
+ ESTRUCTURA TERRITORIAL +

Áreas

+

Las Áreas y Yacimientos son contexto de navegación. El Inventario real comienza en las Instalaciones/Subinstalaciones efectivamente registradas.

+
+
+ {realTotal} Inventario real + {areas.length} Áreas +
- {companies.length === 0 ? :
{companies.map((item) => )}
} -
-
1EmpresaInventario principal
-
2ÁreaContexto territorial
-
3YacimientoNivel territorial
-
4InstalaciónPlanta, batería, estación…
-
5EquipoEquipo, pozo, tanque…
+ {areas.length === 0 + ? + :
{areas.map((area) => )}
} +
+
1ÁreaAncla territorial
+
2YacimientoContexto dentro del Área
+
3InstalaciónInventario real
+
4SubinstalaciónInventario real
; } - const breadcrumb =