import { createHash } from 'node:crypto'; import { MigrationInterface, QueryRunner } from 'typeorm'; import { F31_FINDING_GROUPS, F31_INSTALLATION_FAMILIES, F31_SUBINSTALLATION_FAMILIES, F31_UNIVERSAL_FINDINGS, } from '../../reference-data/f3-1-inventory-excel'; function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } function findingKey(value: string): string { return value .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim() .replace(/\s+/g, ' '); } function findingCode(title: string): string { return `APP26R2-${createHash('sha1').update(findingKey(title)).digest('hex').slice(0, 12).toUpperCase()}`; } type IdRow = { id: string }; type CountRow = { total: string }; export class PhaseF31InventoryStructureCatalog1789844400000 implements MigrationInterface { name = 'PhaseF31InventoryStructureCatalog1789844400000'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` INSERT INTO source_documents (document_type,document_number,title,issuer,notes) SELECT 'SPREADSHEET','DH-F31-APP','APLICACION APP(2).xlsx','Dirección de Hidrocarburos', 'F3.1: revisión estructural. Hoja1 define Instalaciones/Subinstalaciones; Hoja2 define catálogo contextual.' WHERE NOT EXISTS ( SELECT 1 FROM source_documents WHERE document_number='DH-F31-APP' AND issuer='Dirección de Hidrocarburos' ) `); await queryRunner.query(` INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role) SELECT 'subinstalacion','Subinstalación', 'Unidad física subordinada a una Instalación. Su familia técnica determina el catálogo contextual de Hallazgos.', false,true,'GENERIC' WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='subinstalacion') `); 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)='subinstalacion' AND lower(parent.code)='instalacion' ON CONFLICT DO NOTHING `); await queryRunner.query(` CREATE TABLE inventory_families ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(120) NOT NULL UNIQUE, name varchar(240) NOT NULL, level varchar(24) NOT NULL, legacy_type_code varchar(120), information_labels jsonb NOT NULL DEFAULT '[]'::jsonb, source_reference varchar(240), is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT chk_inventory_families_level CHECK (level IN ('INSTALLATION','SUBINSTALLATION')), CONSTRAINT chk_inventory_families_information_labels CHECK (jsonb_typeof(information_labels)='array') ) `); await queryRunner.query(` CREATE INDEX idx_inventory_families_level_active ON inventory_families(level,is_active,name) `); await queryRunner.query(` CREATE TABLE inventory_family_parent_rules ( child_family_id uuid PRIMARY KEY, parent_family_id uuid NOT NULL, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_inventory_family_parent_child FOREIGN KEY (child_family_id) REFERENCES inventory_families(id) ON DELETE CASCADE, CONSTRAINT fk_inventory_family_parent_parent FOREIGN KEY (parent_family_id) REFERENCES inventory_families(id) ON DELETE CASCADE, CONSTRAINT chk_inventory_family_parent_distinct CHECK (child_family_id <> parent_family_id) ) `); await queryRunner.query(` CREATE INDEX idx_inventory_family_parent_parent ON inventory_family_parent_rules(parent_family_id,child_family_id) `); await queryRunner.query(` ALTER TABLE assets ADD COLUMN inventory_family_id uuid `); await queryRunner.query(` ALTER TABLE assets ADD CONSTRAINT fk_assets_inventory_family FOREIGN KEY (inventory_family_id) REFERENCES inventory_families(id) ON DELETE RESTRICT `); await queryRunner.query(` CREATE INDEX idx_assets_inventory_family ON assets(inventory_family_id) `); await queryRunner.query(` CREATE TABLE finding_catalog_item_inventory_families ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), catalog_item_id uuid NOT NULL, inventory_family_id uuid NOT NULL, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uq_finding_catalog_item_inventory_family UNIQUE (catalog_item_id,inventory_family_id), CONSTRAINT fk_finding_catalog_family_item FOREIGN KEY (catalog_item_id) REFERENCES finding_catalog_items(id) ON DELETE CASCADE, CONSTRAINT fk_finding_catalog_family_family FOREIGN KEY (inventory_family_id) REFERENCES inventory_families(id) ON DELETE CASCADE ) `); await queryRunner.query(` CREATE INDEX idx_finding_catalog_family_family ON finding_catalog_item_inventory_families(inventory_family_id,catalog_item_id) `); for (const family of F31_INSTALLATION_FAMILIES) { await queryRunner.query(` INSERT INTO inventory_families (code,name,level,legacy_type_code,information_labels,source_reference,is_active) VALUES ($1,$2,'INSTALLATION',$3,'[]'::jsonb,$4,true) ON CONFLICT (code) DO UPDATE SET name=EXCLUDED.name, legacy_type_code=EXCLUDED.legacy_type_code, source_reference=EXCLUDED.source_reference, is_active=true, updated_at=CURRENT_TIMESTAMP `, [family.code, family.name, family.legacyTypeCode, `APLICACION APP(2).xlsx|Hoja1|fila:${family.sourceRow}`]); } for (const family of F31_SUBINSTALLATION_FAMILIES) { await queryRunner.query(` INSERT INTO inventory_families (code,name,level,legacy_type_code,information_labels,source_reference,is_active) VALUES ($1,$2,'SUBINSTALLATION',$3,$4::jsonb,$5,true) ON CONFLICT (code) DO UPDATE SET name=EXCLUDED.name, legacy_type_code=EXCLUDED.legacy_type_code, information_labels=EXCLUDED.information_labels, source_reference=EXCLUDED.source_reference, is_active=true, updated_at=CURRENT_TIMESTAMP `, [ family.code, family.name, family.legacyTypeCode, JSON.stringify(family.informationLabels), `APLICACION APP(2).xlsx|Hoja1|fila:${family.sourceRow}`, ]); await queryRunner.query(` INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) SELECT child.id,parent.id FROM inventory_families child CROSS JOIN inventory_families parent WHERE child.code=$1 AND parent.code=$2 ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id `, [family.code, family.parentCode]); } await queryRunner.query(` UPDATE finding_categories SET is_active=false, updated_at=CURRENT_TIMESTAMP WHERE lower(code)='app26' `); await queryRunner.query(` INSERT INTO finding_categories(code,name,sort_order,is_active) SELECT 'APP26R2','Aplicación APP 2026 · revisión estructural',261,true WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)='app26r2') `); await queryRunner.query(` UPDATE finding_categories SET name='Aplicación APP 2026 · revisión estructural', sort_order=261, is_active=true, updated_at=CURRENT_TIMESTAMP WHERE lower(code)='app26r2' `); const categoryRows = (await queryRunner.query( `SELECT id FROM finding_categories WHERE lower(code)='app26r2' LIMIT 1`, )) as IdRow[]; const categoryId = categoryRows[0]?.id; if (!categoryId) throw new Error('F3.1 could not resolve APP26R2 category'); const groupByKey = new Map(F31_FINDING_GROUPS.map((group) => [group.key, group])); const titlesByFamily = new Map>(); const addFamilyTitle = (familyCode: string, title: string): void => { const clean = title.trim(); if (!clean || /^idem\b/i.test(clean) || clean.toUpperCase()==='HALLAZGOS') return; const key = findingKey(clean); const map = titlesByFamily.get(familyCode) ?? new Map(); if (!map.has(key)) map.set(key, clean); titlesByFamily.set(familyCode, map); }; const seedFamily = (family: { code: string; groupKeys: string[]; directFindings: string[] }): void => { for (const title of F31_UNIVERSAL_FINDINGS) addFamilyTitle(family.code, title); for (const groupKey of family.groupKeys) { const group = groupByKey.get(groupKey); if (!group) throw new Error(`F3.1 missing finding group ${groupKey}`); for (const title of group.items) addFamilyTitle(family.code, title); } for (const title of family.directFindings) addFamilyTitle(family.code, title); }; for (const family of F31_INSTALLATION_FAMILIES) seedFamily(family); for (const family of F31_SUBINSTALLATION_FAMILIES) seedFamily(family); const allTitles = new Map(); for (const titles of titlesByFamily.values()) { for (const [key,title] of titles) if (!allTitles.has(key)) allTitles.set(key,title); } const orderedTitles = [...allTitles.entries()].sort((a,b) => a[1].localeCompare(b[1],'es')); const itemIdByKey = new Map(); let sourceNumber = 1; for (const [key,title] of orderedTitles) { const code = findingCode(title); let rows = (await queryRunner.query( `SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::text) LIMIT 1`, [code], )) as IdRow[]; if (!rows[0]?.id) { rows = (await queryRunner.query(` INSERT INTO finding_catalog_items (category_id,code,source_number,title,import_note,revision,is_active) VALUES ($1::uuid,$2::varchar,$3::integer,$4::varchar,$5::text,1,true) RETURNING id `, [ categoryId, code, sourceNumber, title, 'APLICACION APP(2).xlsx · F3.1 · catálogo estructural revisado', ])) as IdRow[]; } else { await queryRunner.query(` UPDATE finding_catalog_items SET category_id=$2::uuid,source_number=$3::integer,title=$4::varchar, import_note=$5::text,is_active=true,updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid `, [rows[0].id, categoryId, sourceNumber, title, 'APLICACION APP(2).xlsx · F3.1 · catálogo estructural revisado']); } const itemId = rows[0]?.id; if (!itemId) throw new Error(`F3.1 could not create catalog item ${title}`); itemIdByKey.set(key,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:F3.1' 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`); const familyMeta = [ ...F31_INSTALLATION_FAMILIES, ...F31_SUBINSTALLATION_FAMILIES, ]; for (const family of familyMeta) { const familyRows = (await queryRunner.query( `SELECT id FROM inventory_families WHERE code=$1 LIMIT 1`, [family.code], )) as IdRow[]; const familyId = familyRows[0]?.id; if (!familyId) throw new Error(`F3.1 missing inventory family ${family.code}`); const titles = titlesByFamily.get(family.code) ?? new Map(); for (const key of titles.keys()) { const itemId = itemIdByKey.get(key); if (!itemId) continue; await queryRunner.query(` INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING `, [itemId,familyId]); } const legacyRows = (await queryRunner.query( `SELECT id FROM asset_types WHERE lower(code)=lower($1::text) LIMIT 1`, [family.legacyTypeCode], )) as IdRow[]; const legacyTypeId = legacyRows[0]?.id; if (legacyTypeId) { await queryRunner.query(` INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason) VALUES ($1::uuid,'F3.1: compatibilidad con activos técnicos históricos usando catálogo APP26R2') ON CONFLICT (asset_type_id) DO UPDATE SET reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP `, [legacyTypeId]); for (const key of titles.keys()) { const itemId = itemIdByKey.get(key); if (!itemId) continue; await queryRunner.query(` INSERT INTO finding_catalog_item_asset_types(catalog_item_id,asset_type_id) VALUES ($1::uuid,$2::uuid) ON CONFLICT DO NOTHING `, [itemId,legacyTypeId]); } } } await queryRunner.query(` INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason) SELECT id,'F3.1: las altas estructurales usan familia técnica contextual; sin familia no se sugiere catálogo.' FROM asset_types WHERE lower(code) IN ('instalacion','subinstalacion') ON CONFLICT (asset_type_id) DO UPDATE SET reason=EXCLUDED.reason,updated_at=CURRENT_TIMESTAMP `); const appRole = process.env.DB_APP_USER; if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER'); const roleRows = (await queryRunner.query('SELECT 1 FROM pg_roles WHERE rolname=$1',[appRole])) as unknown[]; if (roleRows.length !== 1) throw new Error('Configured DB_APP_USER does not exist'); const applicationRole = quoteIdentifier(appRole); await queryRunner.query(`GRANT SELECT ON TABLE inventory_families TO ${applicationRole}`); await queryRunner.query(`GRANT SELECT ON TABLE inventory_family_parent_rules TO ${applicationRole}`); await queryRunner.query(`GRANT SELECT ON TABLE finding_catalog_item_inventory_families TO ${applicationRole}`); const familyCounts = (await queryRunner.query(` SELECT COUNT(*) FILTER (WHERE level='INSTALLATION')::text AS installations, COUNT(*) FILTER (WHERE level='SUBINSTALLATION')::text AS subinstallations FROM inventory_families WHERE is_active=true `)) as Array<{ installations: string; subinstallations: string }>; if (Number(familyCounts[0]?.installations ?? 0) !== F31_INSTALLATION_FAMILIES.length) { throw new Error('F3.1 verification failed: installation-family count mismatch'); } if (Number(familyCounts[0]?.subinstallations ?? 0) !== F31_SUBINSTALLATION_FAMILIES.length) { throw new Error('F3.1 verification failed: subinstallation-family count mismatch'); } const catalogCounts = (await queryRunner.query(` SELECT COUNT(*)::text AS total FROM finding_catalog_items item JOIN finding_categories category ON category.id=item.category_id WHERE lower(category.code)='app26r2' AND item.is_active=true `)) as CountRow[]; if (Number(catalogCounts[0]?.total ?? 0) !== orderedTitles.length) { throw new Error('F3.1 verification failed: APP26R2 catalog count mismatch'); } // eslint-disable-next-line no-console console.log( `[F3.1] families=${F31_INSTALLATION_FAMILIES.length}+${F31_SUBINSTALLATION_FAMILIES.length}; groups=${F31_FINDING_GROUPS.length}; catalog=${orderedTitles.length}`, ); } public async down(): Promise { throw new Error( 'F3.1 recarga catálogo y agrega familias estructurales; no se revierte destructivamente. Restaurar backup PRE si fuera necesario.', ); } }