diff --git a/api-v3/src/database/migrations/1789754400000-phase-f2-2-1-reference-excel-data.ts b/api-v3/src/database/migrations/1789754400000-phase-f2-2-1-reference-excel-data.ts new file mode 100644 index 0000000..456a87a --- /dev/null +++ b/api-v3/src/database/migrations/1789754400000-phase-f2-2-1-reference-excel-data.ts @@ -0,0 +1,414 @@ +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', +}; + +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 territoryDocumentRows = (await queryRunner.query( + `SELECT id FROM source_documents WHERE document_number='DH-F221-TERRITORIO' AND issuer='Dirección de Hidrocarburos' LIMIT 1`, + )) as Array<{ id: string }>; + const appDocumentRows = (await queryRunner.query( + `SELECT id FROM source_documents WHERE document_number='DH-F221-APP' AND issuer='Dirección de Hidrocarburos' LIMIT 1`, + )) as Array<{ id: string }>; + const territoryDocumentId = territoryDocumentRows[0]?.id; + const appDocumentId = appDocumentRows[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); + if (!areaId) { + const areaCode = stableCode('AREA', row.area); + 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,$2,$3,'VALIDATED','IN_SERVICE','IMPORT',$4,$5,$6,0 + FROM asset_types type + WHERE type.operational_role='AREA' + AND NOT EXISTS (SELECT 1 FROM assets WHERE lower(code)=lower($1)) + LIMIT 1`, + [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}`], + ); + const rows = (await queryRunner.query(`SELECT id FROM assets WHERE lower(code)=lower($1) LIMIT 1`, [areaCode])) as Array<{ id: string }>; + areaId = rows[0]?.id; + 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,$2,'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,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) + ON CONFLICT (asset_id,definition_id) DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP`, + [areaId, definitionCode, value], + ); + } + } + + let companyId: string | null = null; + const noOperator = slug(row.operator) === 'sin_empresa_operadora'; + if (!noOperator) { + const companyKey = row.operator.trim().toLocaleLowerCase('es'); + companyId = companyCache.get(companyKey) ?? null; + if (!companyId) { + const companyCode = stableCode('ORG', row.operator); + 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,$2,'Organización operadora importada desde Tablas de yacimiento.','VALIDATED','IN_SERVICE','IMPORT',$3,$4,'Empresa/Operadora informada por la fuente',0 + FROM asset_types type + WHERE type.operational_role='COMPANY' + AND NOT EXISTS (SELECT 1 FROM assets WHERE lower(code)=lower($1)) + LIMIT 1`, + [companyCode, row.operator, 'Tablas de yacimiento(1).xlsx', `cr26e_tabla1|OPERADORA|${row.operator}`], + ); + const rows = (await queryRunner.query(`SELECT id FROM assets WHERE lower(code)=lower($1) LIMIT 1`, [companyCode])) as Array<{ id: string }>; + companyId = rows[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,CASE WHEN lower($2) LIKE 'ute %' OR lower($2) LIKE 'ute(%' THEN 'UTE'::organization_kind ELSE 'COMPANY'::organization_kind END,$2 + WHERE NOT EXISTS (SELECT 1 FROM organization_profiles WHERE asset_id=$1)`, + [companyId, row.operator], + ); + await queryRunner.query( + `INSERT INTO asset_source_documents (asset_id,document_id,relation_type,notes) + VALUES ($1,$2,'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,$2,'OPERATOR',CURRENT_TIMESTAMP,'Importado desde Tablas de yacimiento(1).xlsx',$3 + WHERE NOT EXISTS ( + SELECT 1 FROM area_company_relations + WHERE area_id=$1 AND company_id=$2 AND relation_role='OPERATOR' AND valid_until IS NULL + )`, + [areaId, companyId, territoryDocumentId], + ); + } + + const fieldCode = stableCode('YAC', row.field, row.area); + 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,$1,$2,$3,$4,$5,'VALIDATED','IN_SERVICE','IMPORT',$6,$7,$8,0 + FROM asset_types type + WHERE lower(type.code)='yacimiento' + AND NOT EXISTS (SELECT 1 FROM assets WHERE lower(code)=lower($3)) + LIMIT 1`, + [areaId, 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}`], + ); + const fieldRows = (await queryRunner.query(`SELECT id FROM assets WHERE lower(code)=lower($1) LIMIT 1`, [fieldCode])) as Array<{ id: string }>; + const fieldId = fieldRows[0]?.id; + 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,$2,'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,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) + ON CONFLICT (asset_id,definition_id) DO UPDATE SET value=EXCLUDED.value,updated_at=CURRENT_TIMESTAMP`, + [fieldId, definitionCode, value], + ); + } + + const rightKey = `${areaId}|${slug(row.concessionType)}`; + if (!seenRights.has(rightKey)) { + seenRights.add(rightKey); + await queryRunner.query( + `INSERT INTO area_legal_rights (area_id,right_type,name,status,source_document_id,notes) + SELECT $1,$2::area_legal_right_type,$3,'ACTIVE',$4,$5 + WHERE NOT EXISTS ( + SELECT 1 FROM area_legal_rights WHERE area_id=$1 AND lower(name)=lower($3) AND source_document_id=$4 + )`, + [areaId, legalRightType(row.concessionType), `Tipo informado: ${row.concessionType}`, 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) { + await queryRunner.query( + `INSERT INTO asset_types (code,name,description,can_be_root,is_active,operational_role) + SELECT $1,$2,'Tipo incorporado desde APLICACION APP(1).xlsx · F2.2.1',false,true,'GENERIC' + WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1))`, + [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) + 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) 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) AND lower(parent.code)=lower($2) + 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) 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); + await queryRunner.query( + `INSERT INTO asset_attribute_definitions (asset_type_id,code,name,data_type,is_required,is_active,sort_order) + SELECT type.id,$2,$3,'TEXT',false,true,$4 FROM asset_types type + WHERE lower(type.code)=lower($1) + AND NOT EXISTS ( + SELECT 1 FROM asset_attribute_definitions existing + WHERE existing.asset_type_id=type.id AND lower(existing.code)=lower($2) + )`, + [entry.type, 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 Array<{ id: string }>; + 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) => { + 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((set) => [...set]))].sort((a, b) => a.localeCompare(b, 'es')); + let sourceNumber = 1; + const itemIdByTitle = new Map(); + for (const title of allTitles) { + const itemCode = `APP26-${createHash('sha1').update(title).digest('hex').slice(0, 12).toUpperCase()}`; + await queryRunner.query( + `INSERT INTO finding_catalog_items (category_id,code,source_number,title,import_note,revision,is_active) + SELECT $1,$2,$3,$4,'Importado desde APLICACION APP(1).xlsx · F2.2.1',1,true + WHERE NOT EXISTS (SELECT 1 FROM finding_catalog_items WHERE lower(code)=lower($2))`, + [categoryId, itemCode, sourceNumber, title], + ); + const itemRows = (await queryRunner.query(`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1) LIMIT 1`, [itemCode])) as Array<{ id: string }>; + if (itemRows[0]?.id) itemIdByTitle.set(title, itemRows[0].id); + sourceNumber += 1; + } + + for (const [typeCode, titles] of titlesByType) { + const typeRows = (await queryRunner.query(`SELECT id FROM asset_types WHERE lower(code)=lower($1) LIMIT 1`, [typeCode])) as Array<{ id: string }>; + const assetTypeId = typeRows[0]?.id; + 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,$2) 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,'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], + ); + } + + 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.'); + } +}