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 index 3bd9f6a..14ebb6c 100644 --- a/api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts +++ b/api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts @@ -7,10 +7,12 @@ import { } 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'; function findingKey(value: string): string { return value.normalize('NFD') @@ -52,6 +54,20 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn 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 @@ -70,17 +86,13 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn `F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`, ]); - const sourceDocumentId = await this.id( - queryRunner, - `SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`, - [CATALOG_DOCUMENT_NUMBER], - 'catalog source document', - ); - + // 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 IS NULL OR source_reference NOT LIKE 'F5:%' + WHERE source_reference LIKE 'APLICACION APP%' + OR source_reference LIKE 'SYSTEM:F3.1:%' `); await queryRunner.query(` UPDATE finding_categories @@ -165,6 +177,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn 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) { @@ -182,17 +195,16 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn [itemCode], ); if (!itemId) { - const rows = (await queryRunner.query(` + 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) + ) 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=rows[0]?.id ?? null; + itemId=itemRows[0]?.id ?? null; } else { await queryRunner.query(` UPDATE finding_catalog_items @@ -226,36 +238,43 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn sourceNumber+=1; } - await queryRunner.query(`DELETE FROM finding_catalog_item_inventory_families`); - + // 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,itemIdByKey); + 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: catálogo contextual por familia técnica. Yacimiento sin familia usa OTROS.' - FROM asset_types WHERE lower(code) IN ('yacimiento','instalacion','subinstalacion') - ON CONFLICT (asset_type_id) DO UPDATE SET - reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP + 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 + 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 ?? {})}`); @@ -265,12 +284,28 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn SELECT COUNT(*)::integer AS total FROM finding_catalog_items WHERE category_id=$1::uuid AND is_active=true - `,[categoryId])) as Array<{total:number}>; + `,[categoryId])) as CountRow[]; if (Number(itemCount?.total ?? 0)!==177) { throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`); } - void sourceDocumentId; + const [universalMappingCount] = (await queryRunner.query(` + SELECT COUNT(DISTINCT mapping.inventory_family_id)::integer AS total + FROM finding_catalog_item_inventory_families mapping + JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id + JOIN inventory_families family ON family.id=mapping.inventory_family_id + WHERE item.category_id=$1::uuid + AND lower(regexp_replace(unaccent(item.title),'[^a-zA-Z0-9]+',' ','g')) + IN ( + lower(regexp_replace(unaccent('ORDEN Y LIMPIEZA'),'[^a-zA-Z0-9]+',' ','g')), + lower(regexp_replace(unaccent('CARTELERIA PREVENTIVA / INFORMATIVA'),'[^a-zA-Z0-9]+',' ','g')), + lower(regexp_replace(unaccent('EXTINTORES'),'[^a-zA-Z0-9]+',' ','g')) + ) + AND family.source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%' + `,[categoryId])) as CountRow[]; + if (Number(universalMappingCount?.total ?? 0)!==123) { + throw new Error(`F5 universal mapping verification failed: ${universalMappingCount?.total ?? 0}`); + } } public async down(queryRunner: QueryRunner): Promise { @@ -279,6 +314,34 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn `SELECT id FROM finding_categories WHERE lower(code)=lower($1) 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 + `,[F5_AUTO_REASON]); + if (categoryId) { await queryRunner.query(` DELETE FROM finding_catalog_item_inventory_families mapping @@ -288,45 +351,63 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn 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 - AND NOT EXISTS ( - SELECT 1 FROM inspection_findings finding WHERE finding.catalog_item_id=item.id - ) - `,[categoryId]); - await queryRunner.query(` - DELETE FROM finding_catalog_items item - WHERE item.category_id=$1::uuid - AND NOT EXISTS ( - SELECT 1 FROM inspection_findings finding WHERE finding.catalog_item_id=item.id - ) - `,[categoryId]); - await queryRunner.query(` - DELETE FROM finding_categories category - WHERE category.id=$1::uuid - AND NOT EXISTS (SELECT 1 FROM finding_catalog_items item WHERE item.category_id=category.id) + 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_families family - WHERE family.source_reference LIKE 'F5:%' - AND NOT EXISTS (SELECT 1 FROM assets asset WHERE asset.inventory_family_id=family.id) + 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:%' + 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 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( @@ -339,8 +420,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn 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) + ) 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, @@ -366,6 +446,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn queryRunner: QueryRunner, familyCode: string, family: F5InstallationCatalogRow|F5SubinstallationCatalogRow, + universalFindings: string[], itemIdByKey: Map, ): Promise { const familyId = await this.id( @@ -375,7 +456,7 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn `family ${familyCode}`, ); const mapped = new Set(); - for (const rawTitle of family.findings) { + for (const rawTitle of [...family.findings,...universalFindings]) { const itemKey=findingKey(rawTitle); if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue; mapped.add(itemKey); @@ -395,23 +476,18 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN DELETE FROM finding_catalog_asset_overrides - WHERE asset_id=NEW.id - AND reason LIKE 'F% familia técnica:%'; - + 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', + SELECT NEW.id,mapping.catalog_item_id,true,$$${F5_AUTO_REASON}$$, 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; + is_enabled=true,reason=$$${F5_AUTO_REASON}$$, + updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP; END IF; RETURN NEW; END $$; @@ -432,18 +508,16 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn 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', + SELECT asset.id,NEW.catalog_item_id,true,$$${F5_AUTO_REASON}$$, 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; + is_enabled=true,reason=$$${F5_AUTO_REASON}$$,updated_at=CURRENT_TIMESTAMP; RETURN NEW; END $$; `); + await queryRunner.query(` DELETE FROM finding_catalog_asset_overrides WHERE reason LIKE 'F% familia técnica:%' @@ -452,18 +526,15 @@ export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationIn 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, - 'F5 familia técnica: catálogo contextual automático', + SELECT asset.id,mapping.catalog_item_id,true,$1, 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='F5 familia técnica: catálogo contextual automático', - updated_at=CURRENT_TIMESTAMP - `); + is_enabled=true,reason=$1,updated_at=CURRENT_TIMESTAMP + `,[F5_AUTO_REASON]); } private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise {