From 7338f277ad3eb6314d0100857dde5873c045d8d2 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 08:55:01 -0300 Subject: [PATCH] =?UTF-8?q?F5.1=20=C2=B7=20Inventario=20limpio,=20jerarqu?= =?UTF-8?q?=C3=ADa=20manual=20y=20Hallazgos=20filtrados=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reset controlado de datos operativos e históricos, nueva jerarquía manual Departamento → Área → Yacimiento → Instalación → Subinstalación, filtros y administración inline de Hallazgos, y corrección de alta con Área sin Operadora. --- .github/workflows/ci.yml | 127 ++++----- api-v3/package.json | 2 +- .../dto/create-inventory-structure.dto.ts | 1 + .../inventory-browser.controller.ts | 10 + .../asset-master/inventory-browser.service.ts | 190 ++++++++----- .../inventory-structure.service.ts | 59 ++-- ...90087400000-f5-1-clean-manual-inventory.ts | 168 +++++++++++ ...-1-clean-manual-inventory-contract.test.ts | 86 ++++++ web-v2/package.json | 2 +- .../features/assets/AssetHierarchyView.tsx | 137 ++++----- .../FindingCatalogTypeApplicabilityPanel.tsx | 75 +++-- web-v2/src/lib/inventoryBrowserApi.ts | 40 ++- web-v2/src/lib/inventoryStructureApi.ts | 2 +- web-v2/src/pages/AssetTypesPage.tsx | 92 ++++-- web-v2/src/pages/AssetsPage.tsx | 61 ++-- web-v2/src/pages/InventoryCreatePage.tsx | 262 ++++++------------ 16 files changed, 816 insertions(+), 498 deletions(-) create mode 100644 api-v3/src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts create mode 100644 api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3389af..ae004e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,12 +80,9 @@ jobs: 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. + # Historical production reset is a one-shot migration that expects the + # production admin. Prove the clean chain reaches that exact guard, + # mark only that historical reset as applied, then continue the chain. bootstrap_log="$(mktemp)" set +e docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log" @@ -104,17 +101,14 @@ jobs: docker compose --env-file .env.example exec -T db \ psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' DO $$ - DECLARE - reset_rows integer; + DECLARE reset_rows integer; BEGIN SELECT COUNT(*) INTO reset_rows FROM typeorm_migrations WHERE name = 'ResetProductionOperationalData1788652800000'; - IF reset_rows <> 0 THEN RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows; END IF; - INSERT INTO typeorm_migrations ("timestamp", name) VALUES (1788652800000, 'ResetProductionOperationalData1788652800000'); END $$; @@ -122,17 +116,23 @@ jobs: docker compose --env-file .env.example --profile tools run --rm migrate + # F5.1 intentionally ends with zero operational/domain instances. The + # technical family and finding masters remain, but territory preload, + # imports, applicability links and old audits are deliberately gone. docker compose --env-file .env.example exec -T db \ psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL' DO $$ DECLARE f5_migrations integer; - real_inventory integer; - source_areas integer; - source_yacimientos integer; + domain_assets integer; + audits integer; + applicability integer; + territory_sources integer; source_installations integer; source_subinstallations integer; source_findings integer; + department_types integer; + area_department_rules integer; BEGIN SELECT COUNT(*) INTO f5_migrations FROM typeorm_migrations @@ -141,28 +141,33 @@ jobs: 'F5CanonicalInventoryHierarchy1790087150000', 'F5AuthoritativeTerritory1790087200000', 'F5OperationalContextCompatibility1790087250000', - 'F5AuthoritativeInventoryCatalog1790087300000' + 'F5AuthoritativeInventoryCatalog1790087300000', + 'F51CleanManualInventory1790087400000' ); - IF f5_migrations <> 5 THEN - RAISE EXCEPTION 'Expected 5 F5 migrations, got %', f5_migrations; + IF f5_migrations <> 6 THEN + RAISE EXCEPTION 'Expected 6 F5/F5.1 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; + SELECT COUNT(*) INTO domain_assets FROM assets; + IF domain_assets <> 0 THEN + RAISE EXCEPTION 'F5.1 clean start must contain 0 Assets, got %', domain_assets; END IF; - SELECT COUNT(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; + SELECT COUNT(*) INTO audits FROM audit_events; + IF audits <> 0 THEN + RAISE EXCEPTION 'F5.1 clean start must contain 0 audit events, got %', audits; + END IF; + + SELECT COUNT(*) INTO applicability FROM finding_catalog_item_inventory_families; + IF applicability <> 0 THEN + RAISE EXCEPTION 'F5.1 clean start must contain 0 finding applicability links, got %', applicability; + END IF; + + SELECT COUNT(*) INTO territory_sources + FROM source_documents + WHERE document_number='DH-F5-TERRITORY'; + IF territory_sources <> 0 THEN + RAISE EXCEPTION 'F5.1 must remove the old territory source preload, got % rows', territory_sources; END IF; SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'), @@ -171,7 +176,7 @@ jobs: 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; + RAISE EXCEPTION 'F5.1 must preserve technical family masters: installations %, subinstallations %', source_installations,source_subinstallations; END IF; SELECT COUNT(*) INTO source_findings @@ -179,42 +184,38 @@ jobs: 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; + RAISE EXCEPTION 'F5.1 must preserve finding master catalog, got %', source_findings; + END IF; + + SELECT COUNT(*) INTO department_types + FROM asset_types + WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true; + IF department_types <> 1 THEN + RAISE EXCEPTION 'Expected one active root Departamento type, got %', department_types; + END IF; + + SELECT COUNT(*) INTO area_department_rules + FROM asset_type_parent_rules rule + JOIN asset_types child ON child.id=rule.child_type_id + JOIN asset_types parent ON parent.id=rule.parent_type_id + WHERE lower(child.code)='area' AND lower(parent.code)='departamento'; + IF area_department_rules <> 1 THEN + RAISE EXCEPTION 'Expected Area → Departamento canonical rule, got %', area_department_rules; END IF; END $$; SQL - # 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' + # F5.1 is intentionally one-way: production rollback is the PRE database + # backup, not migration:revert. Prove instead that the completed chain is + # idempotent and has no pending migration on a second run. + rerun_log="$(mktemp)" + docker compose --env-file .env.example --profile tools run --rm migrate 2>&1 | tee "$rerun_log" + grep -Eq 'No pending migrations|Applied migrations: 0' "$rerun_log" || { + echo "ERROR: F5.1 migration chain is not idempotent." >&2 + cat "$rerun_log" >&2 + exit 1 + } + rm -f "$rerun_log" - name: VPS-equivalent isolated API preflight run: | set -Eeuo pipefail diff --git a/api-v3/package.json b/api-v3/package.json index 269edaa..d39e17a 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.26.0-1", + "version": "0.27.0-1", "private": true, "license": "UNLICENSED", "scripts": { 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 cf675a9..3263939 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 @@ -11,6 +11,7 @@ import { export const INVENTORY_STRUCTURE_KINDS = [ 'EMPRESA', + 'DEPARTAMENTO', 'AREA', 'YACIMIENTO', 'INSTALACION', diff --git a/api-v3/src/asset-master/inventory-browser.controller.ts b/api-v3/src/asset-master/inventory-browser.controller.ts index 04f84e6..339e499 100644 --- a/api-v3/src/asset-master/inventory-browser.controller.ts +++ b/api-v3/src/asset-master/inventory-browser.controller.ts @@ -13,6 +13,16 @@ export class InventoryBrowserController { return this.inventoryBrowser.items(query); } + @Get('departments') + departments(@Query() query: InventoryBrowserQueryDto) { + return this.inventoryBrowser.departments(query); + } + + @Get('companies') + companies(@Query() query: InventoryBrowserQueryDto) { + return this.inventoryBrowser.companies(query); + } + @Get('areas') areas(@Query() query: InventoryBrowserQueryDto) { return this.inventoryBrowser.areas(query); diff --git a/api-v3/src/asset-master/inventory-browser.service.ts b/api-v3/src/asset-master/inventory-browser.service.ts index 10e6d30..edc1230 100644 --- a/api-v3/src/asset-master/inventory-browser.service.ts +++ b/api-v3/src/asset-master/inventory-browser.service.ts @@ -16,9 +16,9 @@ export class InventoryBrowserService { async items(query: InventoryBrowserQueryDto) { const params: unknown[] = []; const conditions = [ - 'asset.is_inventory_instance=true', "asset.information_status<>'INACTIVE'", 'type.is_active=true', + "(type.operational_role='COMPANY' OR lower(type.code) IN ('departamento','area','yacimiento','instalacion','subinstalacion'))", ]; const add = (value: unknown): string => { params.push(value); @@ -36,15 +36,21 @@ export class InventoryBrowserService { if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'"); if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)'); - if (query.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::uuid`); + if (query.operationalAreaId) { + const area = add(query.operationalAreaId); + conditions.push(`(asset.id=${area}::uuid OR asset.operational_area_id=${area}::uuid)`); + } if (query.operatorCompanyId) { const company = add(query.operatorCompanyId); - conditions.push(`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 + conditions.push(`( + asset.id=${company}::uuid + OR EXISTS ( + SELECT 1 FROM area_company_relations relation + WHERE (relation.area_id=asset.operational_area_id OR relation.area_id=asset.id) + AND relation.company_id=${company}::uuid + AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL + ) )`); } @@ -76,7 +82,8 @@ export class InventoryBrowserService { SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) FROM area_company_relations relation JOIN assets company ON company.id=relation.company_id - WHERE relation.area_id=asset.operational_area_id + WHERE relation.area_id=COALESCE(asset.operational_area_id, + CASE WHEN type.operational_role='AREA' THEN asset.id ELSE NULL END) AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL ORDER BY relation.valid_from DESC,relation.created_at DESC @@ -84,7 +91,8 @@ export class InventoryBrowserService { ) AS "operatorCompany", asset.information_status AS "informationStatus", asset.operational_status AS "operationalStatus", - 0::integer AS "childrenCount", + (SELECT COUNT(*)::integer FROM assets child + WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount", EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry", CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType", (SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount", @@ -104,7 +112,14 @@ export class InventoryBrowserService { LIMIT 1 ) geometry_type ON true WHERE ${where} - ORDER BY asset.name,asset.code + ORDER BY CASE + WHEN lower(type.code)='departamento' THEN 0 + WHEN type.operational_role='COMPANY' THEN 1 + WHEN lower(type.code)='area' THEN 2 + WHEN lower(type.code)='yacimiento' THEN 3 + WHEN lower(type.code)='instalacion' THEN 4 + WHEN lower(type.code)='subinstalacion' THEN 5 ELSE 9 END, + asset.name,asset.code LIMIT ${limit} OFFSET ${offsetParam} `,params); @@ -119,28 +134,30 @@ export class InventoryBrowserService { }; } - async areas(query: InventoryBrowserQueryDto) { + async departments(query: InventoryBrowserQueryDto) { const params: unknown[] = []; const conditions = [ - "type.operational_role='AREA'", - "area.information_status<>'INACTIVE'", + "lower(type.code)='departamento'", + "department.information_status<>'INACTIVE'", 'type.is_active=true', ]; const add = (value: unknown): string => { params.push(value); return `$${params.length}`; }; - if (query.search?.trim()) { const p = add(`%${query.search.trim()}%`); - conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`); + conditions.push(`(department.code ILIKE ${p} OR department.name ILIKE ${p} OR COALESCE(department.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 + SELECT 1 + FROM assets area + JOIN asset_types atype ON atype.id=area.asset_type_id + JOIN area_company_relations relation ON relation.area_id=area.id + WHERE area.parent_id=department.id + AND lower(atype.code)='area' AND relation.company_id=${p}::uuid AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL @@ -149,41 +166,86 @@ export class InventoryBrowserService { const data = await this.dataSource.query(` SELECT - area.id,area.code,area.name,area.common_name AS "commonName", + department.id,department.code,department.name,department.common_name AS "commonName", JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, - 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 + department.information_status AS "informationStatus", + department.operational_status AS "operationalStatus", + (SELECT COUNT(*)::integer FROM assets area + JOIN asset_types atype ON atype.id=area.asset_type_id + WHERE area.parent_id=department.id AND lower(atype.code)='area' + AND area.information_status<>'INACTIVE') AS "areaCount", + (SELECT COUNT(*)::integer FROM assets child + WHERE child.parent_id=department.id AND child.information_status<>'INACTIVE') AS "childrenCount" + FROM assets department + JOIN asset_types type ON type.id=department.asset_type_id + WHERE ${conditions.join(' AND ')} + ORDER BY department.name,department.code + `, params); + return { data, meta: { count: data.length } }; + } + + async companies(query: InventoryBrowserQueryDto) { + const params: unknown[] = []; + const conditions = [ + "type.operational_role='COMPANY'", + "company.information_status<>'INACTIVE'", + 'type.is_active=true', + ]; + if (query.search?.trim()) { + params.push(`%${query.search.trim()}%`); + conditions.push(`(company.code ILIKE $1 OR company.name ILIKE $1 OR COALESCE(company.common_name,'') ILIKE $1)`); + } + const data = await this.dataSource.query(` + SELECT company.id,company.code,company.name,company.common_name AS "commonName", + JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, + company.information_status AS "informationStatus", + (SELECT COUNT(*)::integer FROM area_company_relations relation + WHERE relation.company_id=company.id AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL) AS "areaCount" + FROM assets company + JOIN asset_types type ON type.id=company.asset_type_id + WHERE ${conditions.join(' AND ')} + ORDER BY company.name,company.code + `,params); + return { data,meta:{count:data.length} }; + } + + async areas(query: InventoryBrowserQueryDto) { + const params: unknown[] = []; + const conditions = [ + "type.operational_role='AREA'", + "area.information_status<>'INACTIVE'", + 'type.is_active=true', + ]; + const add = (value: unknown): string => { params.push(value); return `$${params.length}`; }; + if (query.search?.trim()) { + const p = add(`%${query.search.trim()}%`); + conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`); + } + if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`); + if (query.operatorCompanyId) { + const p = add(query.operatorCompanyId); + conditions.push(`EXISTS (SELECT 1 FROM area_company_relations relation + WHERE relation.area_id=area.id AND relation.company_id=${p}::uuid + AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL)`); + } + const data = await this.dataSource.query(` + SELECT area.id,area.code,area.name,area.common_name AS "commonName", + JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, + area.information_status AS "informationStatus",area.operational_status AS "operationalStatus", + (SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) + FROM area_company_relations relation JOIN assets company ON company.id=relation.company_id + WHERE relation.area_id=area.id AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL + ORDER BY relation.valid_from DESC,relation.created_at DESC LIMIT 1) AS "currentOperator", + (SELECT COUNT(*)::integer FROM assets yacimiento JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id + WHERE yacimiento.parent_id=area.id AND lower(ytype.code)='yacimiento' + AND yacimiento.information_status<>'INACTIVE') AS "yacimientoCount", + (SELECT COUNT(*)::integer FROM assets inventory + WHERE inventory.information_status<>'INACTIVE' AND inventory.operational_area_id=area.id) AS "inventoryCount" + FROM assets area JOIN asset_types type ON type.id=area.asset_type_id WHERE ${conditions.join(' AND ')} ORDER BY area.name,area.code `, params); - return { data, meta: { count: data.length } }; } @@ -199,9 +261,6 @@ export class InventoryBrowserService { "asset.information_status<>'INACTIVE'", 'type.is_active=true', ]; - if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') { - conditions.push('asset.is_inventory_instance=true'); - } if (query.search?.trim()) { params.push(`%${query.search.trim()}%`); const p = `$${params.length}`; @@ -223,20 +282,12 @@ export class InventoryBrowserService { CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id',family.id,'code',family.code,'name',family.name,'level',family.level ) END AS "inventoryFamily", - ( - SELECT COUNT(*)::integer - FROM assets child - WHERE child.parent_id=asset.id - AND child.information_status<>'INACTIVE' - 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", + (SELECT COUNT(*)::integer FROM assets child + WHERE child.parent_id=asset.id AND child.information_status<>'INACTIVE') AS "childrenCount", + CASE WHEN family.id IS NULL THEN 0 ELSE ( + SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping + WHERE mapping.inventory_family_id=family.id + ) END AS "findingCount", EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry", asset.updated_at AS "updatedAt" FROM assets asset @@ -263,10 +314,10 @@ export class InventoryBrowserService { 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())) { + if (!['departamento','area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) { throw new BadRequestException({ code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID', - message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación', + message:'La navegación de Inventarios admite Departamento → Área → Yacimiento → Instalación → Subinstalación', }); } return parent; @@ -274,6 +325,7 @@ export class InventoryBrowserService { private allowedChildType(typeCode: string): string | null { switch (typeCode.toLowerCase()) { + case 'departamento': return 'area'; case 'area': return 'yacimiento'; case 'yacimiento': return 'instalacion'; case 'instalacion': return 'subinstalacion'; diff --git a/api-v3/src/asset-master/inventory-structure.service.ts b/api-v3/src/asset-master/inventory-structure.service.ts index 196f427..9424d9f 100644 --- a/api-v3/src/asset-master/inventory-structure.service.ts +++ b/api-v3/src/asset-master/inventory-structure.service.ts @@ -38,8 +38,10 @@ type ParentRow = { typeCode: string; inventoryFamilyId: string | null; }; +type IdRow = { id: string }; const TYPE_CODE_BY_KIND: Record, string> = { + DEPARTAMENTO: 'departamento', AREA: 'area', YACIMIENTO: 'yacimiento', INSTALACION: 'instalacion', @@ -47,7 +49,8 @@ const TYPE_CODE_BY_KIND: Record, stri }; const PARENT_TYPE_BY_KIND: Record = { EMPRESA: null, - AREA: null, + DEPARTAMENTO: null, + AREA: 'departamento', YACIMIENTO: 'area', INSTALACION: 'yacimiento', SUBINSTALACION: 'instalacion', @@ -70,22 +73,24 @@ export class InventoryStructureService { SELECT id,code,name FROM asset_types WHERE ( - lower(code) IN ('area','yacimiento','instalacion','subinstalacion') + lower(code) IN ('departamento','area','yacimiento','instalacion','subinstalacion') OR operational_role='COMPANY' ) AND is_active=true ORDER BY CASE WHEN operational_role='COMPANY' THEN 0 - WHEN lower(code)='area' THEN 1 - WHEN lower(code)='yacimiento' THEN 2 - WHEN lower(code)='instalacion' THEN 3 - WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END + WHEN lower(code)='departamento' THEN 1 + WHEN lower(code)='area' THEN 2 + WHEN lower(code)='yacimiento' THEN 3 + WHEN lower(code)='instalacion' THEN 4 + WHEN lower(code)='subinstalacion' THEN 5 ELSE 9 END `)) as StructureTypeRow[]; const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase())); + const departamento = types.find((item) => item.code.toLowerCase()==='departamento'); const area = types.find((item) => item.code.toLowerCase()==='area'); const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento'); const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion'); const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion'); - if (!company || !area || !yacimiento || !instalacion || !subinstalacion) { + if (!company || !departamento || !area || !yacimiento || !instalacion || !subinstalacion) { throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE', message: 'La configuración maestra de Empresa e Inventario todavía no está completa', @@ -109,7 +114,8 @@ export class InventoryStructureService { { kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false }, ], levels: [ - { kind: 'AREA', label: 'Área', type: area, parentKind: null, requiresFamily: false }, + { kind: 'DEPARTAMENTO', label: 'Departamento', type: departamento, parentKind: null, requiresFamily: false }, + { kind: 'AREA', label: 'Área', type: area, parentKind: 'DEPARTAMENTO', requiresFamily: false }, { kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false }, { kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true }, { kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true }, @@ -121,7 +127,7 @@ export class InventoryStructureService { async parents(kindValue: string, search?: string) { const kind = kindValue.toUpperCase() as InventoryStructureKind; - if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') { + if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'DEPARTAMENTO' || kind === 'EMPRESA') { throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID', message: 'El nivel indicado no requiere un registro padre', @@ -153,7 +159,7 @@ export class InventoryStructureService { AND asset.information_status<>'INACTIVE' ${searchSql} ORDER BY asset.name,asset.code - LIMIT 80 + LIMIT 100 `, parameters); return { data: rows }; } @@ -169,7 +175,9 @@ export class InventoryStructureService { const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null); const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent); const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); - const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null; + const operationalAreaId = parent && ['YACIMIENTO','INSTALACION','SUBINSTALACION'].includes(dto.kind) + ? await this.resolveAreaId(manager, parent) + : null; const inserted = (await manager.query(` INSERT INTO assets ( @@ -193,9 +201,9 @@ export class InventoryStructureService { AssetInformationStatus.DRAFT, AssetOperationalStatus.UNKNOWN, AssetDataOrigin.MANUAL, - dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5', + dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas F5.1' : 'Estructura manual de Inventario F5.1', dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`, - family ? `Familia técnica: ${family.code} · ${family.name}` : null, + family ? `Clasificación técnica: ${family.code} · ${family.name}` : null, principal.userId, ])) as Array<{ id: string }>; const id = inserted[0]?.id; @@ -225,7 +233,7 @@ export class InventoryStructureService { id, parent?.id ?? null, operationalAreaId, - dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5', + dto.kind === 'EMPRESA' ? 'Alta manual de Empresa independiente F5.1' : 'Alta manual de estructura de Inventario F5.1', versionNumber, request.requestId, principal.userId, @@ -285,7 +293,7 @@ export class InventoryStructureService { 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', + : 'Un Departamento es un registro raíz y no puede tener padre', }); } return null; @@ -309,7 +317,7 @@ export class InventoryStructureService { if (parent.typeCode.toLowerCase() !== expectedType) { throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_PARENT_INVALID', - message: 'La jerarquía requerida es Área → Yacimiento → Instalación → Subinstalación', + message: 'La jerarquía requerida es Departamento → Área → Yacimiento → Instalación → Subinstalación', }); } return parent; @@ -349,13 +357,13 @@ export class InventoryStructureService { if (!expectedLevel) { if (familyId) throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED', - message: 'Empresa, Área y Yacimiento no llevan familia técnica', + message: 'Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica', }); return null; } if (!familyId) throw new BadRequestException({ code: 'INVENTORY_STRUCTURE_FAMILY_REQUIRED', - message: `Elegí la familia técnica de la ${kind.toLowerCase()}`, + message: `Elegí la clasificación técnica de la ${kind.toLowerCase()}`, }); const rows = (await manager.query(` SELECT family.id,family.code,family.name,family.level, @@ -369,22 +377,27 @@ export class InventoryStructureService { LIMIT 1 `, [familyId])) as FamilyRow[]; const family = rows[0]; - if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La familia técnica no existe' }); + if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'La clasificación técnica no existe' }); if (family.level !== expectedLevel) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', - message: 'La familia técnica no corresponde al nivel seleccionado', + message: 'La clasificación técnica no corresponde al nivel seleccionado', }); if (kind === 'SUBINSTALACION' && family.parentFamilyId !== parent?.inventoryFamilyId) { throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', - message: 'La Subinstalación elegida no pertenece a la familia de la Instalación seleccionada', + message: 'La Subinstalación elegida no pertenece a la clasificación de la Instalación seleccionada', }); } return family; } private generatedCode(kind: InventoryStructureKind, name: string): string { - const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA'; + const prefix = kind === 'EMPRESA' ? 'EMP' + : kind === 'DEPARTAMENTO' ? 'DEP' + : kind === 'AREA' ? 'AREA' + : kind === 'YACIMIENTO' ? 'YAC' + : kind === 'INSTALACION' ? 'INST' + : 'SUB'; const readable = name .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') @@ -417,5 +430,3 @@ export class InventoryStructureService { return rows[0]; } } - -type IdRow = { id: string }; diff --git a/api-v3/src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts b/api-v3/src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts new file mode 100644 index 0000000..e3379de --- /dev/null +++ b/api-v3/src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts @@ -0,0 +1,168 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F51CleanManualInventory1790087400000 implements MigrationInterface { + name = 'F51CleanManualInventory1790087400000'; + + public async up(queryRunner: QueryRunner): Promise { + // F5.1 is an intentional clean-start cut. The deployment process creates a + // full database backup before migrations, so old domain data is recovered + // from that backup rather than by pretending a destructive migration can + // reconstruct historical rows. + await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets'); + + // Remove all operational/domain instances and every table that depends on + // them (visits, acts, findings, reports, versions, media, relations, etc.). + // Users/roles/permissions and technical configuration are deliberately not + // part of this TRUNCATE. + await queryRunner.query('TRUNCATE TABLE assets CASCADE'); + + // Imported territorial/source material must not silently repopulate or + // influence the new manually curated structure. + await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE'); + await queryRunner.query('TRUNCATE TABLE source_documents CASCADE'); + + // Start the classification ↔ finding applicability review from zero while + // preserving both master catalogs themselves. + await queryRunner.query('TRUNCATE TABLE finding_catalog_item_inventory_families'); + + // Explicitly clear audit history, including authentication/admin events + // accumulated during development. New events continue to be recorded after + // this migration. + await queryRunner.query('TRUNCATE TABLE audit_events'); + + // Import/reconciliation tables can contain rows not connected to a current + // Asset. Clear every asset_import_* data table without coupling this cut to + // one historical import implementation. + await queryRunner.query(` + DO $$ + DECLARE table_name text; + BEGIN + FOR table_name IN + SELECT tablename + FROM pg_tables + WHERE schemaname = current_schema() + AND tablename LIKE 'asset_import_%' + LOOP + EXECUTE format('TRUNCATE TABLE %I CASCADE', table_name); + END LOOP; + END $$; + `); + + // F5.1 decouples the physical Inventory tree from Empresa. A structural + // Asset may therefore inherit an Area while no operator has been assigned + // yet. Keep the useful invariant that an operator can never exist without + // an Area, but remove the old all-or-nothing pair requirement. + await queryRunner.query(` + ALTER TABLE asset_context_history + DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair + `); + await queryRunner.query(` + ALTER TABLE asset_context_history + ADD CONSTRAINT chk_asset_context_history_context_pair + CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL) + `); + + // Departamento becomes the real root of the physical Inventory tree. + await queryRunner.query(` + INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role) + SELECT 'departamento','Departamento','Departamento administrativo que contiene Áreas.',true,true,'GENERIC' + WHERE NOT EXISTS ( + SELECT 1 FROM asset_types WHERE lower(code)='departamento' + ) + `); + await queryRunner.query(` + UPDATE asset_types + SET name='Departamento', + description='Departamento administrativo que contiene Áreas.', + can_be_root=true, + is_active=true, + operational_role='GENERIC', + updated_at=CURRENT_TIMESTAMP + WHERE lower(code)='departamento' + `); + await queryRunner.query(` + UPDATE asset_types + SET can_be_root=false,updated_at=CURRENT_TIMESTAMP + WHERE lower(code)='area' + `); + + // Area has exactly one canonical structural parent kind: Departamento. + await queryRunner.query(` + DELETE FROM asset_type_parent_rules rule + USING asset_types child + WHERE rule.child_type_id=child.id AND lower(child.code)='area' + `); + await queryRunner.query(` + INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id) + SELECT child.id,parent.id + FROM asset_types child CROSS JOIN asset_types parent + WHERE lower(child.code)='area' AND lower(parent.code)='departamento' + ON CONFLICT (child_type_id,parent_type_id) DO NOTHING + `); + + // Database-level guard: UI/API bugs cannot create an invalid physical tree. + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE child_code text; parent_code text; + BEGIN + SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id; + + IF child_code IN ('empresa','organizacion','departamento') THEN + IF NEW.parent_id IS NOT NULL THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Departamento son maestros raíz independientes'; + END IF; + RETURN NEW; + END IF; + + IF child_code NOT IN ('area','yacimiento','instalacion','subinstalacion') THEN + RETURN NEW; + END IF; + + IF NEW.parent_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La estructura requiere Departamento → Área → Yacimiento → Instalación → Subinstalación'; + END IF; + + SELECT lower(type.code) INTO parent_code + FROM assets parent + JOIN asset_types type ON type.id=parent.asset_type_id + WHERE parent.id=NEW.parent_id; + + IF (child_code='area' AND parent_code<>'departamento') + OR (child_code='yacimiento' AND parent_code<>'area') + OR (child_code='instalacion' AND parent_code<>'yacimiento') + OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía inválida: Departamento → Área → Yacimiento → Instalación → Subinstalación'; + END IF; + RETURN NEW; + END $$; + `); + await queryRunner.query(` + CREATE TRIGGER trg_f5_canonical_asset_hierarchy + BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets + FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy() + `); + + const rows = (await queryRunner.query(` + SELECT + (SELECT COUNT(*)::integer FROM assets) AS assets, + (SELECT COUNT(*)::integer FROM audit_events) AS audits, + (SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families) AS applicability, + (SELECT COUNT(*)::integer FROM asset_types WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true) AS departments, + (SELECT COUNT(*)::integer + FROM asset_type_parent_rules rule + JOIN asset_types child ON child.id=rule.child_type_id + JOIN asset_types parent ON parent.id=rule.parent_type_id + WHERE lower(child.code)='area' AND lower(parent.code)='departamento') AS area_rules + `)) as Array<{ assets: number; audits: number; applicability: number; departments: number; area_rules: number }>; + const check = rows[0]; + if (!check || Number(check.assets) !== 0 || Number(check.audits) !== 0 || Number(check.applicability) !== 0 + || Number(check.departments) !== 1 || Number(check.area_rules) !== 1) { + throw new Error(`F5.1 clean-start verification failed: ${JSON.stringify(check ?? {})}`); + } + } + + public async down(): Promise { + throw new Error('F5.1 is an intentional destructive clean-start migration. Restore the pre-deploy database backup to recover previous data.'); + } +} diff --git a/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts b/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts new file mode 100644 index 0000000..9c36c80 --- /dev/null +++ b/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +function source(path: string) { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +test('F5.1 clean start deletes domain instances and audit history but preserves master configuration by contract', () => { + const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts'); + assert.match(migration, /TRUNCATE TABLE assets CASCADE/); + assert.match(migration, /TRUNCATE TABLE audit_events/); + assert.match(migration, /TRUNCATE TABLE administrative_departments CASCADE/); + assert.match(migration, /TRUNCATE TABLE finding_catalog_item_inventory_families/); + assert.doesNotMatch(migration, /TRUNCATE TABLE inventory_families/); + assert.doesNotMatch(migration, /TRUNCATE TABLE finding_catalog_items/); + assert.doesNotMatch(migration, /TRUNCATE TABLE users/); +}); + +test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root', () => { + const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts'); + const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts'); + const structure = source('src/asset-master/inventory-structure.service.ts'); + + assert.match(dto, /'DEPARTAMENTO'/); + assert.match(structure, /DEPARTAMENTO: 'departamento'/); + assert.match(structure, /AREA: 'departamento'/); + assert.match(structure, /Departamento → Área → Yacimiento → Instalación → Subinstalación/); + assert.match(migration, /child_code='area' AND parent_code<>'departamento'/); + assert.match(migration, /WHERE lower\(code\)='area'/); + assert.match(migration, /SET can_be_root=false/); +}); + +test('F5.1 manual creation permits Area context without requiring an operator', () => { + const migration = source('src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts'); + const structure = source('src/asset-master/inventory-structure.service.ts'); + + assert.match(migration, /DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair/); + assert.match(migration, /CHECK \(operator_company_id IS NULL OR operational_area_id IS NOT NULL\)/); + assert.match(structure, /operator_company_id,inventory_family_id/); + assert.match(structure, /asset_context_history/); + assert.match(structure, /operational_area_id,operator_company_id/); +}); + +test('F5.1 Inventory browser exposes every canonical level instead of filtering by is_inventory_instance', () => { + const browser = source('src/asset-master/inventory-browser.service.ts'); + const controller = source('src/asset-master/inventory-browser.controller.ts'); + + assert.match(browser, /'departamento','area','yacimiento','instalacion','subinstalacion'/); + assert.match(browser, /case 'departamento': return 'area'/); + assert.doesNotMatch(browser, /['"]asset\.is_inventory_instance=true['"]/); + assert.match(controller, /@Get\('departments'\)/); + assert.match(controller, /@Get\('companies'\)/); +}); + +test('F5.1 Finding Catalog defaults to associated findings and exposes all items only for linking', () => { + const panel = source('../web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx'); + assert.match(panel, /type ViewMode = 'ASSOCIATED' \| 'ALL'/); + assert.match(panel, /useState\('ASSOCIATED'\)/); + assert.match(panel, /available\.filter\(\(item\) => savedIds\.has\(item\.id\)\)/); + assert.match(panel, /Buscar dentro de \{viewMode === 'ASSOCIATED' \? 'los asociados' : 'todo el Catálogo'\}/); + assert.match(panel, /Todos para vincular/); + assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/); +}); + +test('F5.1 Configuration manages findings inline and filters Subinstallations by Installation', () => { + const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx'); + + assert.match(configPage, /replaceInventoryFamilyFindings/); + assert.match(configPage, /Agregar o quitar/); + assert.match(configPage, /familyFindingIds/); + assert.match(configPage, /selectedInstallationFamilyId/); + assert.match(configPage, /family\.parentFamilyId === selectedInstallationFamilyId/); + assert.match(configPage, /Filtrar por tipo de Instalación/); +}); + +test('F5.1 Web creation and configuration expose Departamento as the physical root', () => { + const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx'); + const configPage = source('../web-v2/src/pages/AssetTypesPage.tsx'); + assert.match(createPage, /kind: 'DEPARTAMENTO'/); + assert.match(createPage, /AREA:'Departamento'/); + assert.match(createPage, /Datos opcionales/); + assert.match(configPage, /Departamento<\/strong>/); + assert.match(configPage, /familyId=\$\{familyEditor\.id\}/); +}); diff --git a/web-v2/package.json b/web-v2/package.json index ade9c9f..6fbef6a 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.21.0-1", + "version": "0.22.0-1", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/features/assets/AssetHierarchyView.tsx b/web-v2/src/features/assets/AssetHierarchyView.tsx index c5f092c..0d113bf 100644 --- a/web-v2/src/features/assets/AssetHierarchyView.tsx +++ b/web-v2/src/features/assets/AssetHierarchyView.tsx @@ -6,11 +6,13 @@ import { Icon } from '../../components/Icon'; import { getAssetLineage } from '../../lib/api'; import type { AssetLineageItem } from '../../lib/api'; import { - listInventoryAreas, + listInventoryCompanies, + listInventoryDepartments, listInventoryChildren, } from '../../lib/inventoryBrowserApi'; import type { - InventoryBrowserArea, + InventoryBrowserCompany, + InventoryBrowserDepartment, InventoryBrowserItem, InventoryQuery, } from '../../lib/inventoryBrowserApi'; @@ -26,28 +28,30 @@ function navigationHref(base: URLSearchParams, parentId?: string) { return `/inventarios${params.size ? `?${params}` : ''}`; } -function AreaCard({ area, href }: { area: InventoryBrowserArea; href: string }) { +function DepartmentCard({ department, href }: { department: InventoryBrowserDepartment; href: string }) { return - {area.name} - - {area.code} · {area.yacimientoCount} yacimiento{area.yacimientoCount === 1 ? '' : 's'} - {area.currentOperator ? ` · Operadora vigente: ${area.currentOperator.name}` : ' · Sin operadora vigente'} - - - - {area.inventoryCount} - instancia{area.inventoryCount === 1 ? '' : 's'} real{area.inventoryCount === 1 ? '' : 'es'} + {department.name} + {department.code} · {department.areaCount} Área{department.areaCount === 1 ? '' : 's'} + {assetStatusLabel(department.informationStatus)} + + ; +} + +function CompanyCard({ company }: { company: InventoryBrowserCompany }) { + return + + {company.name}{company.code} · {company.areaCount} Área{company.areaCount === 1 ? '' : 's'} operada{company.areaCount === 1 ? '' : 's'} + {assetStatusLabel(company.informationStatus)} ; } function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) { - const structural = !item.isInventoryInstance; return - + {item.name} @@ -57,9 +61,10 @@ function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: strin - {structural - ? <>Contexto{item.childrenCount} nivel{item.childrenCount === 1 ? '' : 'es'} inferior{item.childrenCount === 1 ? '' : 'es'} - : <>{assetStatusLabel(item.informationStatus)}{assetOperationalStatusLabel(item.operationalStatus)}} + {assetStatusLabel(item.informationStatus)} + {item.inventoryFamily + ? {item.findingCount} Hallazgo{item.findingCount === 1 ? '' : 's'} asociado{item.findingCount === 1 ? '' : 's'} + : {item.childrenCount} registro{item.childrenCount === 1 ? '' : 's'} inferior{item.childrenCount === 1 ? '' : 'es'}} ; @@ -67,6 +72,7 @@ function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: strin function nextLevelLabel(typeCode: string | undefined) { switch (typeCode?.toLowerCase()) { + case 'departamento': return 'Áreas'; case 'area': return 'Yacimientos'; case 'yacimiento': return 'Instalaciones'; case 'instalacion': return 'Subinstalaciones'; @@ -79,7 +85,8 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) { const canCreate = hasPermission('assets.create'); const [searchParams] = useSearchParams(); const parentId = searchParams.get('parentId') ?? ''; - const [areas, setAreas] = useState([]); + const [departments, setDepartments] = useState([]); + const [companies, setCompanies] = useState([]); const [children, setChildren] = useState([]); const [lineage, setLineage] = useState([]); const [hasMore, setHasMore] = useState(false); @@ -88,66 +95,53 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) { useEffect(() => { let active = true; - setLoading(true); - setError(''); - setAreas([]); - setChildren([]); - setLineage([]); - setHasMore(false); - + setLoading(true); setError(''); setDepartments([]); setCompanies([]); setChildren([]); setLineage([]); setHasMore(false); const run = async () => { if (!parentId) { - const response = await listInventoryAreas({ - search: filters.search, - operationalAreaId: filters.operationalAreaId, - operatorCompanyId: filters.operatorCompanyId, - }); - if (active) setAreas(response.data); + const [loadedDepartments, loadedCompanies] = await Promise.all([ + listInventoryDepartments({ search: filters.search, operatorCompanyId: filters.operatorCompanyId }), + listInventoryCompanies({ search: filters.search }), + ]); + if (active) { setDepartments(loadedDepartments.data); setCompanies(loadedCompanies.data); } return; } - 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); + setLineage(loadedLineage.filter((item) => ['departamento','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; }; - }, [parentId, filters.search, filters.operationalAreaId, filters.operatorCompanyId]); + }, [parentId, filters.search, filters.operatorCompanyId]); - if (loading) return ; + if (loading) return ; if (!parentId) { - const realTotal = areas.reduce((sum, area) => sum + Number(area.inventoryCount ?? 0), 0); return
{error && {error}}
-
- 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 -
+
INVENTARIO COMPLETO

Departamentos

Entrá por Departamento y navegá Área → Yacimiento → Instalación → Subinstalación hasta llegar a su clasificación y Hallazgos asociados.

+
{departments.length} Departamentos{companies.length} Empresas
- {areas.length === 0 - ? - :
{areas.map((area) => )}
} + {departments.length === 0 + ? + :
{departments.map((department) => )}
} +
-
1ÁreaAncla territorial
-
2YacimientoContexto dentro del Área
-
3InstalaciónInventario real
-
4SubinstalaciónInventario real
+
1Departamentoraíz territorial
+
2Áreadentro del Departamento
+
3Yacimientodentro del Área
+
4Instalaciónclasificación técnica
+
5Subinstalaciónclasificación técnica
+ +
+

Empresas

Maestro independiente. La Empresa se vincula al Área como operadora sin alterar la estructura física.

{companies.length}
+ {companies.length === 0 ?
Todavía no hay Empresas cargadas.
:
{companies.map((company) => )}
} +
; } @@ -156,10 +150,7 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) { Inventarios {lineage.map((item,index) => { const isLast=index===lineage.length-1; - return - - {isLast ? {item.name} : {item.name}} - ; + return {isLast ? {item.name} : {item.name}}; })} ; @@ -168,31 +159,17 @@ export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) { {error && {error}} {hasMore && Este nivel tiene más de 200 registros. Usá la búsqueda para acotar el resultado.}
-
- {current?.type.name ?? 'INVENTARIO'} -

{current?.name ?? 'Nivel de Inventario'}

-

{current?.code ?? ''}

-
+
{current?.type.name ?? 'INVENTARIO'}

{current?.name ?? 'Nivel de Inventario'}

{current?.code ?? ''}

- {current && Ver ficha} + {current && Ver ficha{['instalacion','subinstalacion'].includes(current.type.code.toLowerCase()) ? ' y Hallazgos' : ''}} {canCreate && current?.type.code.toLowerCase() !== 'subinstalacion' && Agregar aquí}
-
-

{nextLevelLabel(current?.type.code)}

La jerarquía permitida es Área → Yacimiento → Instalación → Subinstalación.

- {children.length} -
+

{nextLevelLabel(current?.type.code)}

Jerarquía: Departamento → Área → Yacimiento → Instalación → Subinstalación.

{children.length}
{children.length === 0 - ? + ? :
{children.map((item) => )}
}
; diff --git a/web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx b/web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx index 29f3871..627dba4 100644 --- a/web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx +++ b/web-v2/src/features/inspections/FindingCatalogTypeApplicabilityPanel.tsx @@ -1,5 +1,6 @@ import { SearchableSelect } from '../../components/SearchableSelect'; import { useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router'; import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback'; import { Icon } from '../../components/Icon'; import { getFindingCatalogAdmin } from '../../lib/api'; @@ -12,19 +13,25 @@ import type { InventoryFamily } from '../../lib/inventoryStructureApi'; const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] }; +type ViewMode = 'ASSOCIATED' | 'ALL'; + export function FindingCatalogTypeApplicabilityPanel() { + const [searchParams] = useSearchParams(); + const requestedFamilyId = searchParams.get('familyId') ?? ''; const [families, setFamilies] = useState([]); const [catalog, setCatalog] = useState(EMPTY_CATALOG); const [familyId, setFamilyId] = useState(''); const [enabled, setEnabled] = useState>(new Set()); - const [reason, setReason] = useState('Actualización de aplicabilidad por clasificación de Inventario'); + const [reason, setReason] = useState('Actualización de Hallazgos asociados a la clasificación de Inventario'); const [search, setSearch] = useState(''); + const [viewMode, setViewMode] = useState('ASSOCIATED'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const selectedFamily = families.find((family) => family.id === familyId) ?? null; + const savedIds = useMemo(() => new Set(selectedFamily?.findingItemIds ?? []), [selectedFamily]); useEffect(() => { Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()]) @@ -32,15 +39,19 @@ export function FindingCatalogTypeApplicabilityPanel() { const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false); setFamilies(activeFamilies); setCatalog(loadedCatalog); - setFamilyId(activeFamilies[0]?.id ?? ''); + setFamilyId(activeFamilies.some((family) => family.id === requestedFamilyId) + ? requestedFamilyId + : activeFamilies[0]?.id ?? ''); }) .catch((requestError) => setError(errorMessage(requestError))) .finally(() => setLoading(false)); - }, []); + }, [requestedFamilyId]); useEffect(() => { const selected = families.find((family) => family.id === familyId); setEnabled(new Set(selected?.findingItemIds ?? [])); + setViewMode('ASSOCIATED'); + setSearch(''); setSuccess(''); setError(''); }, [familyId, families]); @@ -53,7 +64,7 @@ export function FindingCatalogTypeApplicabilityPanel() { catalog.categories.map((category) => [category.id, category.name]), ), [catalog.categories]); - const visible = useMemo(() => { + const available = useMemo(() => { const needle = search.trim().toLocaleLowerCase('es-AR'); return catalog.items.filter((item) => item.isActive @@ -63,12 +74,23 @@ export function FindingCatalogTypeApplicabilityPanel() { ); }, [catalog.items, search, activeCategoryIds, categoryName]); + const visible = useMemo(() => viewMode === 'ASSOCIATED' + ? available.filter((item) => savedIds.has(item.id)) + : available, + [available, savedIds, viewMode]); + const toggle = (id: string) => setEnabled((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); + const selectVisible = () => setEnabled((current) => { + const next = new Set(current); + visible.forEach((item) => next.add(item.id)); + return next; + }); + const save = async () => { if (!selectedFamily) return; setSaving(true); setError(''); setSuccess(''); @@ -82,6 +104,7 @@ export function FindingCatalogTypeApplicabilityPanel() { setFamilies((current) => current.map((family) => family.id === selectedFamily.id ? { ...family, findingItemIds: itemIds, findingCount: itemIds.length } : family)); + setViewMode('ASSOCIATED'); setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`); } catch (requestError) { setError(errorMessage(requestError)); @@ -98,9 +121,9 @@ export function FindingCatalogTypeApplicabilityPanel() {
APLICABILIDAD POR INSTALACIÓN / SUBINSTALACIÓN

Qué Hallazgos verá el inspector

-

Los Hallazgos se vinculan a la clasificación concreta del elemento, no a una “función”. La opción OTROS permanece siempre disponible en la APK.

+

Al elegir una clasificación se filtra inmediatamente a sus Hallazgos asociados. Para modificar la relación, abrí “Todos para vincular”.

- {enabled.size} vinculados + {savedIds.size} vinculados {error && {error}}{success && {success}}
@@ -112,21 +135,39 @@ export function FindingCatalogTypeApplicabilityPanel() { )} - +
{selectedFamily &&
-

{selectedFamily.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'}: {selectedFamily.parentFamilyName ? `${selectedFamily.parentFamilyName} → ` : ''}{selectedFamily.name}. Actualmente tiene {selectedFamily.findingCount ?? enabled.size} Hallazgo{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'} asociado{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'}.

+

{selectedFamily.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'}: {selectedFamily.parentFamilyName ? `${selectedFamily.parentFamilyName} → ` : ''}{selectedFamily.name}. Tiene {savedIds.size} Hallazgo{savedIds.size === 1 ? '' : 's'} asociado{savedIds.size === 1 ? '' : 's'}.

} -
- - + +
+ +
-
{visible.map((item) => )}
-