import { createHash } from 'node:crypto'; import { MigrationInterface, QueryRunner } from 'typeorm'; import { F61_PRESENTATION_TERRITORY_SOURCE as SOURCE } from '../../reference-data/f6-1-presentation-territory-source'; type IdRow={id:string}; const NO_OPERATOR='Sin Empresa Operadora'; const DOC='DH-F6.1-PRESENTATION-TERRITORY-20260909'; const key=(value:string)=>value.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase() .replace(/[^a-z0-9]+/g,' ').trim().replace(/\s+/g,' '); const code=(prefix:string,value:string,length=12)=> `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0,length).toUpperCase()}`; const uniqueBy=(rows:T[],identity:(row:T)=>string)=>{ const seen=new Set(); return rows.filter((row)=>{const id=identity(row);if(seen.has(id))return false;seen.add(id);return true;}); }; export class F61PresentationTerritoryReset1790094600000 implements MigrationInterface { name='F61PresentationTerritoryReset1790094600000'; public async up(q:QueryRunner):Promise{ const rows=SOURCE.rows; if(SOURCE.file!=='Tablas de yacimiento y areas(1).xlsx'||SOURCE.sheet!=='cr26e_tabla1' ||SOURCE.sha256!=='afc8991eed0c0175cf121b6e11e6ca7cc5459ba371c5a186966706e386033cdb'||rows.length!==230) throw new Error('F6.1 presentation source contract mismatch'); const areas=uniqueBy(rows,(r)=>key(r.area)); const pairs=uniqueBy(rows,(r)=>`${key(r.area)}|${key(r.yacimiento)}`); const departments=uniqueBy(rows,(r)=>key(r.departamento)); const companies=[...new Set(rows.map((r)=>r.empresaOperadora.trim()) .filter((name)=>name&&key(name)!==key(NO_OPERATOR)))].sort((a,b)=>a.localeCompare(b,'es')); if(areas.length!==64||pairs.length!==230||departments.length!==7||companies.length!==12 ||areas.filter((r)=>key(r.empresaOperadora)!==key(NO_OPERATOR)).length!==47) throw new Error('F6.1 presentation source cardinality mismatch'); const concessions=new Set(rows.map((r)=>r.tipoConcesion.trim())); if(concessions.size!==2||!concessions.has('Exploración')||!concessions.has('Explotación')) throw new Error('F6.1 presentation concession contract mismatch'); for(const area of areas){ const same=rows.filter((r)=>key(r.area)===key(area.area)); for(const field of ['departamento','tipoConcesion','empresaOperadora'] as const) if(new Set(same.map((r)=>key(r[field]))).size!==1) throw new Error(`Conflicting ${field}: ${area.area}`); } const companyType=await this.type(q, `SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`); const departmentType=await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='departamento' AND is_active=true AND can_be_root=true LIMIT 1`); const areaType=await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='area' AND operational_role='AREA' AND is_active=true LIMIT 1`); const yacimientoType=await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`); const [rules]=await q.query(` SELECT COUNT(*)::integer AS total FROM asset_type_parent_rules rule JOIN asset_types child ON child.id=rule.child_type_id JOIN asset_types parent ON parent.id=rule.parent_type_id WHERE (lower(child.code)='area' AND lower(parent.code)='departamento') OR (lower(child.code)='yacimiento' AND lower(parent.code)='area')`); if(Number(rules?.total)!==2) throw new Error('F6.1 canonical hierarchy rules are incomplete'); // Destructive clean start requested for the presentation. Users, RBAC, // technical families, Finding masters and system configuration are preserved. // Recovery is the automatic PRE database backup made by deploy-github.sh. await q.query('TRUNCATE TABLE assets CASCADE'); await q.query('TRUNCATE TABLE administrative_departments CASCADE'); await q.query('TRUNCATE TABLE source_documents CASCADE'); await q.query('TRUNCATE TABLE audit_events'); await q.query(`DO $$ DECLARE t text; BEGIN FOR t IN SELECT tablename FROM pg_tables WHERE schemaname=current_schema() AND tablename LIKE 'asset_import_%' LOOP EXECUTE format('TRUNCATE TABLE %I CASCADE',t); END LOOP; END $$;`); const [doc]=(await q.query(`INSERT INTO source_documents(document_type,document_number,title,issuer,external_reference,notes) VALUES('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4) RETURNING id`, [DOC,SOURCE.file,`sha256:${SOURCE.sha256}`,`F6.1 · ${SOURCE.sheet} · ${rows.length} filas`])) as IdRow[]; if(!doc?.id) throw new Error('Could not create F6.1 presentation source'); const companyIds=new Map(); for(const name of companies){ const id=await this.asset(q,companyType,null,null,code('PRES-ORG',key(name)),name, 'Empresa/Operadora del corte de presentación.',`F6.1:PRESENTATION:COMPANY:${code('SRC',key(name),10)}`,'Empresa'); companyIds.set(key(name),id); await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name) VALUES($1::uuid,$2::organization_kind,$3)`,[id,name.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',name]); await this.link(q,id,doc.id,'Empresa/Operadora'); } const departmentIds=new Map(); for(const row of departments.sort((a,b)=>a.departamento.localeCompare(b.departamento,'es'))){ const id=await this.asset(q,departmentType,null,null,code('PRES-DEP',key(row.departamento)),row.departamento, 'Departamento administrativo raíz F6.1.',`F6.1:PRESENTATION:DEPARTMENT:${code('SRC',key(row.departamento),10)}`,'Departamento'); departmentIds.set(key(row.departamento),id); await this.link(q,id,doc.id,'Departamento'); } const areaIds=new Map(); for(const row of areas.sort((a,b)=>a.area.localeCompare(b.area,'es'))){ const parent=departmentIds.get(key(row.departamento)); if(!parent)throw new Error(`Missing Departamento ${row.departamento}`); const id=await this.asset(q,areaType,parent,null,code('PRES-AREA',key(row.area)),row.area, `Área del Departamento ${row.departamento}.`,`F6.1:PRESENTATION:AREA:${code('SRC',key(row.area),10)}`,'Área'); areaIds.set(key(row.area),id); await this.link(q,id,doc.id,`Área · ${row.departamento}`); await q.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,'ACTIVE',$4::uuid,$5)`,[ id,row.tipoConcesion==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION', `${row.tipoConcesion} · ${row.area}`,doc.id,`F6.1 · ${SOURCE.file}`]); if(key(row.empresaOperadora)!==key(NO_OPERATOR)){ const company=companyIds.get(key(row.empresaOperadora)); if(!company)throw new Error(`Missing Empresa ${row.empresaOperadora}`); await q.query(`INSERT INTO area_company_relations(area_id,company_id,relation_role,source_document_id,valid_from,start_reason) VALUES($1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4)`, [id,company,doc.id,`F6.1 · operadora vigente según ${SOURCE.file}`]); } } for(const row of pairs){ const area=areaIds.get(key(row.area)); if(!area)throw new Error(`Missing Área ${row.area}`); const id=await this.asset(q,yacimientoType,area,area,code('PRES-YAC',`${key(row.area)}|${key(row.yacimiento)}`), row.yacimiento,`Yacimiento del Área ${row.area}.`, `F6.1:PRESENTATION:YAC:${code('SRC',`${key(row.area)}|${key(row.yacimiento)}`,12)}`,`${SOURCE.sheet} · fila ${row.sourceRow}`); await this.link(q,id,doc.id,`Yacimiento · ${row.area} · fila ${row.sourceRow}`); } const [c]=await q.query(`SELECT (SELECT COUNT(*) FROM assets)::integer total_assets, (SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE t.operational_role='COMPANY')::integer companies, (SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='departamento')::integer departments, (SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area')::integer areas, (SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='yacimiento')::integer yacimientos, (SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE')::integer legal_rights, (SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLORATION_PERMIT')::integer exploration_rights, (SELECT COUNT(*) FROM area_legal_rights WHERE status='ACTIVE' AND right_type='EXPLOITATION_CONCESSION')::integer exploitation_rights, (SELECT COUNT(*) FROM area_company_relations WHERE relation_role='OPERATOR' AND valid_until IS NULL)::integer operators, (SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE lower(t.code)='area' AND NOT EXISTS(SELECT 1 FROM area_company_relations r WHERE r.area_id=a.id AND r.relation_role='OPERATOR' AND r.valid_until IS NULL))::integer areas_without_operator, (SELECT COUNT(*) FROM assets a JOIN asset_types t ON t.id=a.asset_type_id LEFT JOIN assets p ON p.id=a.parent_id LEFT JOIN asset_types pt ON pt.id=p.asset_type_id WHERE lower(t.code)='area' AND (p.id IS NULL OR lower(pt.code)<>'departamento'))::integer invalid_area_parents, (SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id LEFT JOIN assets a ON a.id=y.parent_id LEFT JOIN asset_types at ON at.id=a.asset_type_id WHERE lower(t.code)='yacimiento' AND (a.id IS NULL OR lower(at.code)<>'area' OR y.operational_area_id IS DISTINCT FROM a.id))::integer invalid_yacimiento_contexts, (SELECT COUNT(*) FROM asset_source_documents WHERE document_id=$1::uuid)::integer source_links, (SELECT COUNT(*) FROM source_documents WHERE document_number=$2)::integer source_documents`,[doc.id,DOC]); const expected:Record={total_assets:313,companies:12,departments:7,areas:64,yacimientos:230, legal_rights:64,exploration_rights:18,exploitation_rights:46,operators:47,areas_without_operator:17, invalid_area_parents:0,invalid_yacimiento_contexts:0,source_links:313,source_documents:1}; for(const [field,value] of Object.entries(expected)) if(Number(c?.[field]??-1)!==value)throw new Error(`F6.1 seed verification failed: ${field}=${c?.[field]} expected=${value}`); } public async down():Promise{ throw new Error('F6.1 presentation reset is destructive. Restore the PRE deploy database backup.'); } private async type(q:QueryRunner,sql:string):Promise{ const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing F6.1 master type: ${sql}`); return rows[0].id; } private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,assetCode:string,name:string, description:string,sourceReference:string,sourceNotes:string):Promise{ const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id, code,name,description,information_status,operational_status,data_origin,source_name,source_reference,source_notes,is_inventory_instance) VALUES($1::uuid,$2::uuid,$3::uuid,NULL,NULL,$4,$5,$6,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$7,$8,$9,false) RETURNING id`, [typeId,parentId,areaId,assetCode,name,description,SOURCE.file,sourceReference,sourceNotes])) as IdRow[]; if(!row?.id)throw new Error(`Could not seed ${name}`); return row.id; } private async link(q:QueryRunner,assetId:string,documentId:string,notes:string):Promise{ await q.query(`INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes) VALUES($1::uuid,$2::uuid,'SOURCE',$3)`,[assetId,documentId,notes]); } }