diff --git a/api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts b/api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts new file mode 100644 index 0000000..3bd9f6a --- /dev/null +++ b/api-v3/src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts @@ -0,0 +1,531 @@ +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 }; + +const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG'; +const CATALOG_CATEGORY_CODE = 'F5MODEL'; +const CATALOG_SOURCE_NAME = 'final_modelov2.xlsx'; + +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'); + } + + 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`, + ]); + + 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', + ); + + 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:%' + `); + 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,'DH · Modelo de Inventarios F5',270,true + WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)=lower($1)) + `, [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) + `,[CATALOG_CATEGORY_CODE]); + const categoryId = await this.id( + queryRunner, + `SELECT id FROM finding_categories WHERE lower(code)=lower($1) 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 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) LIMIT 1`, + [itemCode], + ); + if (!itemId) { + const rows = (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=rows[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; + } + + await queryRunner.query(`DELETE FROM finding_catalog_item_inventory_families`); + + for (const family of source.catalogSource.installations) { + await this.mapFindings(queryRunner,installationCode(family.name),family,itemIdByKey); + } + for (const family of source.catalogSource.subinstallations) { + await this.mapFindings( + queryRunner, + subinstallationCode(family.installation,family.name), + family, + itemIdByKey, + ); + } + + await this.installFamilySyncFunctions(queryRunner); + + 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 + `); + + 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 Array<{total:number}>; + if (Number(itemCount?.total ?? 0)!==177) { + throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`); + } + + void sourceDocumentId; + } + + public async down(queryRunner: QueryRunner): Promise { + const categoryId = await this.optionalId( + queryRunner, + `SELECT id FROM finding_categories WHERE lower(code)=lower($1) LIMIT 1`, + [CATALOG_CATEGORY_CODE], + ); + 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 + 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) + `,[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) + `); + 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 source_documents + WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' + `,[CATALOG_DOCUMENT_NUMBER]); + await this.restoreF31FamilySyncFunctions(queryRunner); + } + + 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 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, + itemIdByKey: Map, + ): Promise { + const familyId = await this.id( + queryRunner, + `SELECT id FROM inventory_families WHERE code=$1 LIMIT 1`, + [familyCode], + `family ${familyCode}`, + ); + const mapped = new Set(); + for (const rawTitle of family.findings) { + 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, + 'F5 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='F5 familia técnica: catálogo contextual automático', + updated_at=CURRENT_TIMESTAMP + `); + } + + 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; + } +}