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; } }