import { createHash } from 'node:crypto'; import { MigrationInterface, QueryRunner } from 'typeorm'; import { loadF221ExcelSource } from '../../reference-data/f2-2-1-excel-source'; function slug(value: string, max = 70): string { const normalized = value .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, ''); return (normalized || 'item').slice(0, max); } function stableCode(prefix: string, ...parts: string[]): string { const hash = createHash('sha1').update(parts.join('|')).digest('hex').slice(0, 8).toUpperCase(); const readable = slug(parts[0] ?? 'item', 72).toUpperCase(); return `${prefix}-${readable}-${hash}`.slice(0, 120); } function legalRightType(value: string): string { const normalized = slug(value); if (normalized.includes('explot')) return 'EXPLOITATION_CONCESSION'; if (normalized.includes('explor')) return 'EXPLORATION_PERMIT'; if (normalized.includes('transport')) return 'TRANSPORT_CONCESSION'; return 'OTHER'; } const groupTargets: Record = { tanques: ['tanque'], separadores: ['separador'], bomba_zona_de_bombas: ['bomba'], calderas: ['caldera'], drenaje: ['drenaje'], antorcha: ['antorcha'], colectores: ['colector'], sist_electrico_iluminacion: ['sistema_electrico_iluminacion'], defensa_contra_incendios: ['defensa_contra_incendios'], cargadero_y_descargadero_de_camiones: ['cargadero_descargadero'], filtros: ['filtro'], eq_flotacion: ['equipo_flotacion'], baterias_y_plantas: ['bateria', 'planta'], fwko_tratadores_calentadores: ['fwo', 'fwko', 'calentador', 'tratador_termico'], bombeo_mecanico: ['bombeo_mecanico'], bombeo_electreosumergible: ['bombeo_electreosumergible'], bombeo_cavidad_progresiva_pcp: ['bombeo_cavidad_progresiva_pcp'], pozos_surgentes_prod_gas: ['pozos_surgentes_prod_gas'], pozos_inyectores_agua: ['pozos_inyectores_agua'], }; const referenceAliases: Record = { tanque: 'tanque', tanques: 'tanque', separador: 'separador', separadores: 'separador', bombas: 'bomba', bomba: 'bomba', fwo: 'fwo', fwko: 'fwko', calentador: 'calentador', piletas: 'drenaje', pileta: 'drenaje', cargadero_descargadero: 'cargadero_descargadero', generador: 'generador', }; type IdRow = { id: string }; type CountRow = { total: string }; async function assetIdByCode(queryRunner: QueryRunner, code: string): Promise { const rows = (await queryRunner.query( `SELECT id FROM assets WHERE lower(code)=lower($1::text) LIMIT 1`, [code], )) as IdRow[]; return rows[0]?.id ?? null; } async function assetTypeIdByCode(queryRunner: QueryRunner, code: string): Promise { const rows = (await queryRunner.query( `SELECT id FROM asset_types WHERE lower(code)=lower($1::text) LIMIT 1`, [code], )) as IdRow[]; return rows[0]?.id ?? null; } export class PhaseF221ReferenceExcelData1789754400000 implements MigrationInterface { name = 'PhaseF221ReferenceExcelData1789754400000'; public async up(queryRunner: QueryRunner): Promise { const source = loadF221ExcelSource(); await queryRunner.query(` INSERT INTO source_documents (document_type,document_number,title,issuer,notes) SELECT 'SPREADSHEET','DH-F221-TERRITORIO','Tablas de yacimiento(1).xlsx','Dirección de Hidrocarburos', 'Fuente F2.2.1: maestro territorial y operativo. Hoja normalizada cr26e_tabla1.' WHERE NOT EXISTS ( SELECT 1 FROM source_documents WHERE document_number='DH-F221-TERRITORIO' AND issuer='Dirección de Hidrocarburos' ) `); await queryRunner.query(` INSERT INTO source_documents (document_type,document_number,title,issuer,notes) SELECT 'SPREADSHEET','DH-F221-APP','APLICACION APP(1).xlsx','Dirección de Hidrocarburos', 'Fuente F2.2.1: matriz de jerarquía, información y catálogo contextual de hallazgos. Hojas Hoja1 y Hoja2.' WHERE NOT EXISTS ( SELECT 1 FROM source_documents WHERE document_number='DH-F221-APP' AND issuer='Dirección de Hidrocarburos' ) `); const territoryDocuments = (await queryRunner.query( `SELECT id FROM source_documents WHERE document_number='DH-F221-TERRITORIO' AND issuer='Dirección de Hidrocarburos' LIMIT 1`, )) as IdRow[]; const appDocuments = (await queryRunner.query( `SELECT id FROM source_documents WHERE document_number='DH-F221-APP' AND issuer='Dirección de Hidrocarburos' LIMIT 1`, )) as IdRow[]; const territoryDocumentId = territoryDocuments[0]?.id; const appDocumentId = appDocuments[0]?.id; if (!territoryDocumentId || !appDocumentId) { throw new Error('F2.2.1 source documents could not be resolved'); } await queryRunner.query( `UPDATE asset_types SET can_be_root=true WHERE operational_role IN ('AREA','COMPANY')`, ); await queryRunner.query(` INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role) SELECT 'yacimiento','Yacimiento', 'Yacimiento físico/operativo perteneciente a un Área. Su operadora se determina por contexto histórico.', false,true,'GENERIC' WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='yacimiento') `); 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)='yacimiento' AND parent.operational_role='AREA' ON CONFLICT DO NOTHING `); await queryRunner.query(` INSERT INTO asset_attribute_definitions (asset_type_id,code,name,data_type,is_required,is_active,sort_order) SELECT type.id,definition.code,definition.name,'TEXT',false,true,definition.sort_order FROM asset_types type CROSS JOIN (VALUES ('departamento_fuente','Departamento informado por la fuente',10), ('tipo_concesion_fuente','Tipo de concesión informado por la fuente',20) ) definition(code,name,sort_order) WHERE lower(type.code)='yacimiento' ON CONFLICT DO NOTHING `); await queryRunner.query(` INSERT INTO asset_attribute_definitions (asset_type_id,code,name,data_type,is_required,is_active,sort_order) SELECT type.id,'tipo_concesion_fuente','Tipo de concesión informado por la fuente', 'TEXT',false,true,40 FROM asset_types type WHERE type.operational_role='AREA' ON CONFLICT DO NOTHING `); const areaCache = new Map(); const companyCache = new Map(); const seenRights = new Set(); for (const row of source.territory) { const areaKey = row.area.trim().toLocaleLowerCase('es'); let areaId = areaCache.get(areaKey) ?? null; if (!areaId) { const areaCode = stableCode('AREA', row.area); areaId = await assetIdByCode(queryRunner, areaCode); if (!areaId) { const inserted = (await queryRunner.query( `INSERT INTO assets ( asset_type_id,parent_id,code,name,description,information_status,operational_status, data_origin,source_name,source_reference,source_notes,current_version ) SELECT type.id,NULL,$1::varchar,$2::varchar,$3::text, 'VALIDATED','IN_SERVICE','IMPORT',$4::varchar,$5::varchar,$6::text,0 FROM asset_types type WHERE type.operational_role='AREA' LIMIT 1 RETURNING id`, [ areaCode, row.area, `Área importada desde Tablas de yacimiento. Departamento: ${row.department}.`, 'Tablas de yacimiento(1).xlsx', `cr26e_tabla1|AREA|${row.area}`, `Departamento=${row.department}; TipoConcesion=${row.concessionType}`, ], )) as IdRow[]; areaId = inserted[0]?.id ?? null; } if (!areaId) throw new Error(`Could not resolve area ${row.area}`); areaCache.set(areaKey, areaId); await queryRunner.query( `INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes) VALUES ($1::uuid,$2::uuid,'SOURCE','Importación F2.2.1 · cr26e_tabla1') ON CONFLICT DO NOTHING`, [areaId, territoryDocumentId], ); } for (const [definitionCode, value] of [ ['departamento', row.department], ['tipo_concesion_fuente', row.concessionType], ] as const) { await queryRunner.query( `INSERT INTO asset_attribute_values (asset_id,definition_id,value) SELECT $1::uuid,definition.id,to_jsonb($3::text) FROM asset_attribute_definitions definition JOIN asset_types type ON type.id=definition.asset_type_id WHERE type.operational_role='AREA' AND lower(definition.code)=lower($2::text) ON CONFLICT (asset_id,definition_id) DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP`, [areaId, definitionCode, value], ); } const noOperator = slug(row.operator) === 'sin_empresa_operadora'; let companyId: string | null = null; if (!noOperator) { const companyKey = row.operator.trim().toLocaleLowerCase('es'); companyId = companyCache.get(companyKey) ?? null; if (!companyId) { const companyCode = stableCode('ORG', row.operator); companyId = await assetIdByCode(queryRunner, companyCode); if (!companyId) { const inserted = (await queryRunner.query( `INSERT INTO assets ( asset_type_id,parent_id,code,name,description,information_status,operational_status, data_origin,source_name,source_reference,source_notes,current_version ) SELECT type.id,NULL,$1::varchar,$2::varchar, 'Organización operadora importada desde Tablas de yacimiento.', 'VALIDATED','IN_SERVICE','IMPORT',$3::varchar,$4::varchar, 'Empresa/Operadora informada por la fuente',0 FROM asset_types type WHERE type.operational_role='COMPANY' LIMIT 1 RETURNING id`, [ companyCode, row.operator, 'Tablas de yacimiento(1).xlsx', `cr26e_tabla1|OPERADORA|${row.operator}`, ], )) as IdRow[]; companyId = inserted[0]?.id ?? null; } if (!companyId) throw new Error(`Could not resolve operator ${row.operator}`); companyCache.set(companyKey, companyId); await queryRunner.query( `INSERT INTO organization_profiles (asset_id,organization_kind,legal_name) SELECT $1::uuid, CASE WHEN lower($2::text) LIKE 'ute %' OR lower($2::text) LIKE 'ute(%' THEN 'UTE'::organization_kind ELSE 'COMPANY'::organization_kind END, $2::varchar WHERE NOT EXISTS (SELECT 1 FROM organization_profiles WHERE asset_id=$1::uuid)`, [companyId, row.operator], ); await queryRunner.query( `INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes) VALUES ($1::uuid,$2::uuid,'SOURCE','Importación F2.2.1 · cr26e_tabla1') ON CONFLICT DO NOTHING`, [companyId, territoryDocumentId], ); } await queryRunner.query( `INSERT INTO area_company_relations (area_id,company_id,relation_role,valid_from,start_reason,source_document_id) SELECT $1::uuid,$2::uuid,'OPERATOR',CURRENT_TIMESTAMP, 'Importado desde Tablas de yacimiento(1).xlsx',$3::uuid WHERE NOT EXISTS ( SELECT 1 FROM area_company_relations WHERE area_id=$1::uuid AND company_id=$2::uuid AND relation_role='OPERATOR' AND valid_until IS NULL )`, [areaId, companyId, territoryDocumentId], ); } const fieldCode = stableCode('YAC', row.field, row.area); let fieldId = await assetIdByCode(queryRunner, fieldCode); if (!fieldId) { // The operational context is intentionally all-or-nothing. A source row // that explicitly says "Sin Empresa Operadora" stays physically under // its Área but receives no fabricated operator and no half context. const operationalAreaId = companyId ? areaId : null; const inserted = (await queryRunner.query( `INSERT INTO assets ( asset_type_id,parent_id,operational_area_id,operator_company_id,code,name,description, information_status,operational_status,data_origin,source_name,source_reference, source_notes,current_version ) SELECT type.id,$1::uuid,$2::uuid,$3::uuid,$4::varchar,$5::varchar,$6::text, 'VALIDATED','IN_SERVICE','IMPORT',$7::varchar,$8::varchar,$9::text,0 FROM asset_types type WHERE lower(type.code)='yacimiento' LIMIT 1 RETURNING id`, [ areaId, operationalAreaId, companyId, fieldCode, row.field, `Yacimiento del Área ${row.area}.`, 'Tablas de yacimiento(1).xlsx', `cr26e_tabla1|YACIMIENTO|${row.field}|AREA|${row.area}`, `Departamento=${row.department}; TipoConcesion=${row.concessionType}; OperadoraFuente=${row.operator}`, ], )) as IdRow[]; fieldId = inserted[0]?.id ?? null; } if (!fieldId) throw new Error(`Could not resolve field ${row.field} / ${row.area}`); await queryRunner.query( `INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes) VALUES ($1::uuid,$2::uuid,'SOURCE','Importación F2.2.1 · cr26e_tabla1') ON CONFLICT DO NOTHING`, [fieldId, territoryDocumentId], ); for (const [definitionCode, value] of [ ['departamento_fuente', row.department], ['tipo_concesion_fuente', row.concessionType], ] as const) { await queryRunner.query( `INSERT INTO asset_attribute_values (asset_id,definition_id,value) SELECT $1::uuid,definition.id,to_jsonb($3::text) FROM asset_attribute_definitions definition JOIN asset_types type ON type.id=definition.asset_type_id WHERE lower(type.code)='yacimiento' AND lower(definition.code)=lower($2::text) ON CONFLICT (asset_id,definition_id) DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP`, [fieldId, definitionCode, value], ); } const rightName = `Tipo informado: ${row.concessionType}`; const rightKey = `${areaId}|${slug(row.concessionType)}`; if (!seenRights.has(rightKey)) { seenRights.add(rightKey); const existing = (await queryRunner.query( `SELECT id FROM area_legal_rights WHERE area_id=$1::uuid AND lower(name)=lower($2::text) AND source_document_id=$3::uuid LIMIT 1`, [areaId, rightName, territoryDocumentId], )) as IdRow[]; if (!existing[0]?.id) { await queryRunner.query( `INSERT INTO area_legal_rights (area_id,right_type,name,status,source_document_id,notes) VALUES ($1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5::text)`, [ areaId, legalRightType(row.concessionType), rightName, territoryDocumentId, `Importado desde cr26e_tabla1. Departamento: ${row.department}.`, ], ); } } } const topLevelTypes = new Set(source.types.map((entry) => entry.code)); for (const pair of source.parentPairs) topLevelTypes.delete(pair.child); const allTypeDefinitions = new Map(source.types.map((entry) => [entry.code, entry.name])); for (const targets of Object.values(groupTargets)) { for (const target of targets) { if (!allTypeDefinitions.has(target)) { allTypeDefinitions.set(target, target.replaceAll('_', ' ')); } } } for (const [typeCode, typeName] of allTypeDefinitions) { if (!(await assetTypeIdByCode(queryRunner, typeCode))) { await queryRunner.query( `INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role) VALUES ($1::varchar,$2::varchar, 'Tipo incorporado desde APLICACION APP(1).xlsx · F2.2.1',false,true,'GENERIC')`, [typeCode, typeName], ); } await queryRunner.query( `INSERT INTO finding_catalog_asset_type_profiles (asset_type_id,reason) SELECT id,'Aplicabilidad inicial importada desde APLICACION APP(1).xlsx' FROM asset_types WHERE lower(code)=lower($1::text) ON CONFLICT (asset_type_id) DO NOTHING`, [typeCode], ); } for (const typeCode of topLevelTypes) { 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)=lower($1::text) AND lower(parent.code)='yacimiento' ON CONFLICT DO NOTHING`, [typeCode], ); } for (const pair of source.parentPairs) { if (pair.child === pair.parent) continue; 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)=lower($1::text) AND lower(parent.code)=lower($2::text) ON CONFLICT DO NOTHING`, [pair.child, pair.parent], ); } for (const targets of Object.values(groupTargets)) { for (const typeCode of targets) { 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)=lower($1::text) AND lower(parent.code)='yacimiento' ON CONFLICT DO NOTHING`, [typeCode], ); } } for (const entry of source.attributes) { let sortOrder = 100; for (const label of entry.labels) { const attributeCode = slug(label, 72); const typeId = await assetTypeIdByCode(queryRunner, entry.type); if (!typeId) continue; const existing = (await queryRunner.query( `SELECT id FROM asset_attribute_definitions WHERE asset_type_id=$1::uuid AND lower(code)=lower($2::text) LIMIT 1`, [typeId, attributeCode], )) as IdRow[]; if (!existing[0]?.id) { await queryRunner.query( `INSERT INTO asset_attribute_definitions (asset_type_id,code,name,data_type,is_required,is_active,sort_order) VALUES ($1::uuid,$2::varchar,$3::varchar,'TEXT',false,true,$4::integer)`, [typeId, attributeCode, label, sortOrder], ); } sortOrder += 10; } } await queryRunner.query(` INSERT INTO finding_categories (code,name,sort_order,is_active) SELECT 'APP26','Aplicación APP 2026',260,true WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)='app26') `); const categoryRows = (await queryRunner.query( `SELECT id FROM finding_categories WHERE lower(code)='app26' LIMIT 1`, )) as IdRow[]; const categoryId = categoryRows[0]?.id; if (!categoryId) throw new Error('Could not resolve APP26 finding category'); const titlesByType = new Map>(); const addTitle = (typeCode: string, title: string): void => { const cleanTitle = title.trim(); if (!cleanTitle || /^idem\b/i.test(cleanTitle)) return; const set = titlesByType.get(typeCode) ?? new Set(); set.add(cleanTitle); titlesByType.set(typeCode, set); }; for (const entry of source.directFindings) { for (const title of entry.titles) addTitle(entry.type, title); } for (const group of source.groups) { const targets = groupTargets[slug(group.name)] ?? [slug(group.name)]; for (const target of targets) { for (const item of group.items) addTitle(target, item.title); } } for (let pass = 0; pass < 3; pass += 1) { for (const entry of source.idemRefs) { for (const rawReference of entry.refs) { const reference = referenceAliases[slug(rawReference)] ?? slug(rawReference); for (const title of titlesByType.get(reference) ?? []) addTitle(entry.type, title); } } } const allTitles = [ ...new Set([...titlesByType.values()].flatMap((titles) => [...titles])), ].sort((a, b) => a.localeCompare(b, 'es')); const itemIdByTitle = new Map(); let sourceNumber = 1; for (const title of allTitles) { const itemCode = `APP26-${createHash('sha1').update(title).digest('hex').slice(0, 12).toUpperCase()}`; let itemRows = (await queryRunner.query( `SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::text) LIMIT 1`, [itemCode], )) as IdRow[]; if (!itemRows[0]?.id) { itemRows = (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, 'Importado desde APLICACION APP(1).xlsx · F2.2.1',1,true) RETURNING id`, [categoryId, itemCode, sourceNumber, title], )) as IdRow[]; } const itemId = itemRows[0]?.id; if (itemId) { itemIdByTitle.set(title, 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:F2.2.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; } for (const [typeCode, titles] of titlesByType) { const assetTypeId = await assetTypeIdByCode(queryRunner, typeCode); if (!assetTypeId) continue; for (const title of titles) { const catalogItemId = itemIdByTitle.get(title); if (!catalogItemId) 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`, [catalogItemId, assetTypeId], ); } } await queryRunner.query( `INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes) SELECT asset.id,$1::uuid,'MENTIONS', 'El tipo/hallazgo del elemento se definió con la matriz APLICACION APP(1).xlsx' FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE lower(type.code)='yacimiento' ON CONFLICT DO NOTHING`, [appDocumentId], ); const importedYacimientos = (await queryRunner.query( `SELECT count(*)::text AS total FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE asset.data_origin='IMPORT' AND asset.source_name='Tablas de yacimiento(1).xlsx' AND lower(type.code)='yacimiento'`, )) as CountRow[]; if (Number(importedYacimientos[0]?.total ?? 0) !== source.territory.length) { throw new Error( `F2.2.1 verification failed: expected ${source.territory.length} imported yacimientos, found ${importedYacimientos[0]?.total ?? '0'}`, ); } const importedCatalog = (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)='app26'`, )) as CountRow[]; if (Number(importedCatalog[0]?.total ?? 0) !== allTitles.length) { throw new Error( `F2.2.1 verification failed: expected ${allTitles.length} APP26 findings, found ${importedCatalog[0]?.total ?? '0'}`, ); } const halfContexts = (await queryRunner.query( `SELECT count(*)::text AS total FROM assets WHERE (operational_area_id IS NULL) <> (operator_company_id IS NULL)`, )) as CountRow[]; if (Number(halfContexts[0]?.total ?? 0) !== 0) { throw new Error('F2.2.1 verification failed: an imported asset has an incomplete operational context'); } // eslint-disable-next-line no-console console.log( `[F2.2.1] imported territory=${source.territory.length}; yacimientos=${importedYacimientos[0]?.total}; catalog=${importedCatalog[0]?.total}; halfContexts=0`, ); } public async down(): Promise { throw new Error( 'F2.2.1 contiene maestros territoriales y catálogo de referencia; no se revierte destructivamente. Restaurar backup PRE si fuera necesario.', ); } }