feat(F6.1): add atomic presentation territory reset
This commit is contained in:
@@ -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=<T>(rows:T[],identity:(row:T)=>string)=>{
|
||||||
|
const seen=new Set<string>(); 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<void>{
|
||||||
|
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<string,string>();
|
||||||
|
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<string,string>();
|
||||||
|
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<string,string>();
|
||||||
|
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<string,number>={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<void>{
|
||||||
|
throw new Error('F6.1 presentation reset is destructive. Restore the PRE deploy database backup.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async type(q:QueryRunner,sql:string):Promise<string>{
|
||||||
|
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<string>{
|
||||||
|
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<void>{
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user