From 91c50c84c25f72080af260409dfbb2a8945c663e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 21:03:33 -0300 Subject: [PATCH] feat(F6.1): add atomic presentation territory reset --- ...00000-f6-1-presentation-territory-reset.ts | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 api-v3/src/database/migrations/1790094600000-f6-1-presentation-territory-reset.ts diff --git a/api-v3/src/database/migrations/1790094600000-f6-1-presentation-territory-reset.ts b/api-v3/src/database/migrations/1790094600000-f6-1-presentation-territory-reset.ts new file mode 100644 index 0000000..764f1e8 --- /dev/null +++ b/api-v3/src/database/migrations/1790094600000-f6-1-presentation-territory-reset.ts @@ -0,0 +1,163 @@ +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]); + } +}