From 68d1af63ff2e0ce8a58b33bd14230079717312b5 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Fri, 11 Sep 2026 00:58:36 -0300 Subject: [PATCH] feat(db): apply authoritative inventory relationships --- ...0-authoritative-inventory-relationships.ts | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 api-v3/src/database/migrations/1790106600000-authoritative-inventory-relationships.ts diff --git a/api-v3/src/database/migrations/1790106600000-authoritative-inventory-relationships.ts b/api-v3/src/database/migrations/1790106600000-authoritative-inventory-relationships.ts new file mode 100644 index 0000000..4c47570 --- /dev/null +++ b/api-v3/src/database/migrations/1790106600000-authoritative-inventory-relationships.ts @@ -0,0 +1,380 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { AUTHORITATIVE_INVENTORY_MODEL as SOURCE } from '../../reference-data/authoritative-inventory-model'; + +type IdRow = { id: string }; + +const TERRITORY_DOC = 'DH-AUTH-TERRITORY-20260911'; +const INVENTORY_DOC = 'DH-AUTH-INVENTORY-20260911'; +const FINDING_CATEGORY_CODE = 'AUTHMODEL'; + +function sourceCode(prefix: string, id: number): string { + return `${prefix}-${String(id).padStart(4, '0')}`; +} + +export class AuthoritativeInventoryRelationships1790106600000 implements MigrationInterface { + name = 'AuthoritativeInventoryRelationships1790106600000'; + + public async up(q: QueryRunner): Promise { + this.assertSource(); + + 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 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 fieldType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`); + const installationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='instalacion' AND is_active=true LIMIT 1`); + const subinstallationType = await this.type(q, `SELECT id FROM asset_types WHERE lower(code)='subinstalacion' AND is_active=true LIMIT 1`); + + await q.query(` + CREATE TABLE IF NOT EXISTS concession_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(80) NOT NULL UNIQUE, + name varchar(120) NOT NULL UNIQUE, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_concession_types_name CHECK (length(btrim(name)) >= 3) + ) + `); + await q.query(`ALTER TABLE assets ADD COLUMN IF NOT EXISTS concession_type_id uuid`); + await q.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='fk_assets_concession_type') THEN + ALTER TABLE assets ADD CONSTRAINT fk_assets_concession_type + FOREIGN KEY (concession_type_id) REFERENCES concession_types(id) ON DELETE RESTRICT; + END IF; + END $$ + `); + await q.query(`CREATE INDEX IF NOT EXISTS idx_assets_concession_type_id ON assets(concession_type_id)`); + await q.query(` + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='dhv2_app') THEN + GRANT SELECT ON concession_types TO dhv2_app; + END IF; + END $$ + `); + + await q.query(`DELETE FROM asset_type_parent_rules WHERE child_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]); + await q.query(` + INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id) VALUES + ($1::uuid,$2::uuid),($3::uuid,$1::uuid),($4::uuid,$3::uuid),($5::uuid,$4::uuid) + ON CONFLICT DO NOTHING + `, [areaType, departmentType, fieldType, installationType, subinstallationType]); + await q.query(`UPDATE asset_types SET can_be_root=true,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid)`, [companyType, departmentType]); + await q.query(`UPDATE asset_types SET can_be_root=false,updated_at=CURRENT_TIMESTAMP WHERE id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid)`, [areaType, fieldType, installationType, subinstallationType]); + await q.query(`UPDATE asset_types SET description='Área territorial. Sólo posee Nombre y pertenece obligatoriamente a un Departamento.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [areaType]); + await q.query(`UPDATE asset_types SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [fieldType]); + await q.query(`UPDATE asset_types SET description='Instalación física. Pertenece obligatoriamente a un Yacimiento y utiliza una clasificación técnica.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [installationType]); + await q.query(`UPDATE asset_types SET description='Subinstalación física. Pertenece obligatoriamente a una Instalación y utiliza una clasificación técnica compatible.',updated_at=CURRENT_TIMESTAMP WHERE id=$1::uuid`, [subinstallationType]); + + await q.query(`DELETE FROM asset_attribute_definitions WHERE asset_type_id IN ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6::uuid)`, [companyType, departmentType, areaType, fieldType, installationType, subinstallationType]); + await this.insertCommonAttributes(q, installationType, [ + ['campo_marca','Marca',10], + ['tipo_instalacion','Tipo de instalación',20], + ['campo_modelo','Modelo',30], + ['campo_capacidad','Capacidad',40], + ['campo_numero_serie','Número de serie',50], + ['campo_funcion','Función',60], + ]); + await this.insertCommonAttributes(q, subinstallationType, [ + ['campo_marca','Marca',10], + ['campo_modelo','Modelo',20], + ['campo_capacidad','Capacidad',30], + ['campo_numero_serie','Número de serie',40], + ['campo_funcion','Función',50], + ]); + + await q.query(` + DO $$ DECLARE trigger_name text; BEGIN + FOR trigger_name IN + SELECT trigger_row.tgname + FROM pg_trigger trigger_row + JOIN pg_proc function_row ON function_row.oid=trigger_row.tgfoid + WHERE trigger_row.tgrelid='assets'::regclass + AND NOT trigger_row.tgisinternal + AND function_row.proname='enforce_asset_operational_context' + LOOP + EXECUTE format('DROP TRIGGER %I ON assets',trigger_name); + END LOOP; + END $$ + `); + await q.query(`DROP FUNCTION IF EXISTS enforce_asset_operational_context()`); + + await q.query(` + CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE + kind text; + asset_role asset_type_operational_role; + parent_kind text; + parent_area uuid; + parent_company uuid; + parent_family uuid; + company_role asset_type_operational_role; + family_level text; + BEGIN + SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id; + IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF; + + IF asset_role='COMPANY'::asset_type_operational_role THEN + IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF; + NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL; + NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + RETURN NEW; + ELSIF kind='departamento' THEN + IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF; + NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL; + NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + RETURN NEW; + END IF; + + IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF; + SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id + INTO parent_kind,parent_area,parent_company,parent_family + FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id + WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE'; + IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF; + + IF kind='area' THEN + IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF; + NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL; + NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + ELSIF kind='yacimiento' THEN + IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF; + IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF; + SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id + WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF; + IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido'; + END IF; + IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF; + NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + ELSIF kind='instalacion' THEN + IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF; + SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true; + IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF; + NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true; + ELSIF kind='subinstalacion' THEN + IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF; + SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true; + IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF; + IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre'; + END IF; + NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true; + END IF; + RETURN NEW; + END $$ + `); + await q.query(`DROP TRIGGER IF EXISTS trg_assets_authoritative_inventory_relationships ON assets`); + await q.query(` + CREATE TRIGGER trg_assets_authoritative_inventory_relationships + BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,operational_area_id,operator_company_id,inventory_family_id,concession_type_id + ON assets FOR EACH ROW EXECUTE FUNCTION enforce_authoritative_inventory_relationships() + `); + + await q.query(`TRUNCATE TABLE assets CASCADE`); + await q.query(`TRUNCATE TABLE source_documents CASCADE`); + await q.query(`TRUNCATE TABLE inventory_families CASCADE`); + await q.query(`TRUNCATE TABLE finding_categories CASCADE`); + await q.query(`TRUNCATE TABLE concession_types CASCADE`); + + const [territoryDoc] = (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 + `, [TERRITORY_DOC,'modelo_yacimientos.sql','sha256:6a1d96bc8af7e755dd0e2fd9faf08335625b9c76865208c3b8413d868aa5eb1a','Modelo relacional exacto de Departamentos, Áreas, Yacimientos, Empresas y Tipo de concesión.'])) as IdRow[]; + const [inventoryDoc] = (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 + `, [INVENTORY_DOC,'modelo_relacional instalaciones y sub.sql','sha256:fb513f3909e78db298361a8a6998ca77026748f9b03cbab44c77b884ca862b49','Modelo relacional exacto de tipos de Instalación/Subinstalación y sus Hallazgos.'])) as IdRow[]; + if(!territoryDoc?.id || !inventoryDoc?.id) throw new Error('Authoritative source documents could not be created'); + + const concessionIds=new Map(); + for(const row of SOURCE.concessionTypes){ + const [created]=(await q.query(`INSERT INTO concession_types(code,name,is_active) VALUES($1,$2,true) RETURNING id`,[sourceCode('CONC',row.id),row.nombre])) as IdRow[]; + if(!created?.id)throw new Error(`Could not create concession ${row.nombre}`); concessionIds.set(row.id,created.id); + } + + await this.seedFamiliesAndFindings(q); + + const companyIds=new Map(); + for(const row of SOURCE.companies){ + const id=await this.asset(q,companyType,null,null,null,null,sourceCode('ORG',row.id),row.nombre,'modelo_yacimientos.sql',`empresas:${row.id}`); + companyIds.set(row.id,id); + await q.query(`INSERT INTO organization_profiles(asset_id,organization_kind,legal_name) VALUES($1::uuid,$2::organization_kind,$3)`,[id,row.nombre.toUpperCase().startsWith('UTE (')?'UTE':'COMPANY',row.nombre]); + await this.link(q,id,territoryDoc.id,`Empresa · source id ${row.id}`); + } + const departmentIds=new Map(); + for(const row of SOURCE.departments){ + const id=await this.asset(q,departmentType,null,null,null,null,sourceCode('DEP',row.id),row.nombre,'modelo_yacimientos.sql',`departamentos:${row.id}`); + departmentIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Departamento · source id ${row.id}`); + } + const areaIds=new Map(); + for(const row of SOURCE.areas){ + const parent=departmentIds.get(row.departamento_id); if(!parent)throw new Error(`Missing source Departamento ${row.departamento_id}`); + const id=await this.asset(q,areaType,parent,null,null,null,sourceCode('AREA',row.id),row.nombre,'modelo_yacimientos.sql',`areas:${row.id}`); + areaIds.set(row.id,id); await this.link(q,id,territoryDoc.id,`Área · source id ${row.id}`); + } + for(const row of SOURCE.fields){ + const area=areaIds.get(row.area_id); const company=companyIds.get(row.empresa_id); const concession=concessionIds.get(row.tipo_concesion_id); + if(!area||!company||!concession)throw new Error(`Incomplete source relations for Yacimiento ${row.id}`); + const id=await this.asset(q,fieldType,area,area,company,concession,sourceCode('YAC',row.id),row.nombre,'modelo_yacimientos.sql',`yacimientos:${row.id}`); + await this.link(q,id,territoryDoc.id,`Yacimiento · source id ${row.id}`); + } + + await q.query(`TRUNCATE TABLE area_company_relations CASCADE`); + await q.query(`TRUNCATE TABLE area_legal_rights CASCADE`); + const projectedPairs=new Set(); + const projectedRights=new Set(); + for(const row of SOURCE.fields){ + const area=areaIds.get(row.area_id)!; const company=companyIds.get(row.empresa_id)!; const concession=concessionIds.get(row.tipo_concesion_id)!; + const pair=`${area}|${company}`; + if(!projectedPairs.has(pair)){ + projectedPairs.add(pair); + 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)`,[area,company,territoryDoc.id,'Derivado automáticamente de Yacimientos del modelo autoritativo']); + } + const rightKey=`${area}|${concession}`; + if(!projectedRights.has(rightKey)){ + projectedRights.add(rightKey); + const concessionRow=SOURCE.concessionTypes.find((item)=>item.id===row.tipo_concesion_id)!; + 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)`,[ + area,concessionRow.nombre==='Exploración'?'EXPLORATION_PERMIT':'EXPLOITATION_CONCESSION', + `${concessionRow.nombre} · proyección del Yacimiento`,territoryDoc.id,'Compatibilidad derivada; el Tipo de concesión canónico está en Yacimiento.']); + } + } + + await this.installFamilyFindingSync(q); + await this.verify(q); + } + + public async down():Promise{ + throw new Error('Authoritative inventory model is a deliberate clean-load migration; restore the deploy PRE database backup.'); + } + + private assertSource():void{ + if(SOURCE.departments.length!==7||SOURCE.companies.length!==13||SOURCE.concessionTypes.length!==2||SOURCE.areas.length!==64||SOURCE.fields.length!==230) + throw new Error('Authoritative territory source cardinality mismatch'); + if(SOURCE.installationFamilies.length!==14||SOURCE.subinstallationFamilies.length!==109||SOURCE.findings.length!==181||SOURCE.installationFindings.length!==48||SOURCE.subinstallationFindings.length!==880) + throw new Error('Authoritative inventory source cardinality mismatch'); + for(const area of SOURCE.areas) if(!SOURCE.departments.some((d)=>d.id===area.departamento_id)) throw new Error(`Area ${area.id} has no source Departamento`); + for(const field of SOURCE.fields){ + if(!SOURCE.areas.some((a)=>a.id===field.area_id)||!SOURCE.companies.some((c)=>c.id===field.empresa_id)||!SOURCE.concessionTypes.some((c)=>c.id===field.tipo_concesion_id)) + throw new Error(`Yacimiento ${field.id} has invalid source relationship`); + } + } + + private async insertCommonAttributes(q:QueryRunner,typeId:string,rows:Array<[string,string,number]>):Promise{ + for(const [code,name,sortOrder] of rows) await q.query(` + INSERT INTO asset_attribute_definitions(asset_type_id,code,name,data_type,is_required,is_active,unit,options,sort_order) + VALUES($1::uuid,$2,$3,'TEXT'::asset_attribute_data_type,false,true,NULL,NULL,$4) + `,[typeId,code,name,sortOrder]); + } + + private async seedFamiliesAndFindings(q:QueryRunner):Promise{ + const installationIds=new Map(); + for(const row of SOURCE.installationFamilies){ + const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active) + VALUES($1,$2,'INSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-I',row.id),row.nombre,`modelo_relacional:instalaciones:${row.id}`])) as IdRow[]; + if(!created?.id)throw new Error(`Could not create Instalación family ${row.id}`); installationIds.set(row.id,created.id); + } + const subIds=new Map(); + for(const row of SOURCE.subinstallationFamilies){ + const [created]=(await q.query(`INSERT INTO inventory_families(code,name,level,information_labels,source_reference,is_active) + VALUES($1,$2,'SUBINSTALLATION','[]'::jsonb,$3,true) RETURNING id`,[sourceCode('AUTH-S',row.id),row.nombre,`modelo_relacional:subinstalaciones:${row.id}`])) as IdRow[]; + const parent=installationIds.get(row.instalacion_id); if(!created?.id||!parent)throw new Error(`Invalid Subinstalación family ${row.id}`); + subIds.set(row.id,created.id); + await q.query(`INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id) VALUES($1::uuid,$2::uuid)`,[created.id,parent]); + } + + const [category]=(await q.query(`INSERT INTO finding_categories(code,name,sort_order,is_active) VALUES($1,$2,300,true) RETURNING id`,[FINDING_CATEGORY_CODE,'DH · Hallazgos del modelo autoritativo'])) as IdRow[]; + if(!category?.id)throw new Error('Could not create authoritative finding category'); + const findingIds=new Map(); + for(const row of SOURCE.findings){ + const [created]=(await q.query(`INSERT INTO finding_catalog_items(category_id,code,source_number,title,import_note,revision,is_active) + VALUES($1::uuid,$2,$3,$4,$5,1,true) RETURNING id`,[category.id,sourceCode('AUTH-H',row.id),row.id,row.nombre,'modelo_relacional instalaciones y sub.sql'])) as IdRow[]; + if(!created?.id)throw new Error(`Could not create Hallazgo ${row.id}`); findingIds.set(row.id,created.id); + await q.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',item.category_id,'code',item.code,'sourceNumber',item.source_number,'title',item.title,'revision',item.revision,'isActive',item.is_active),'migration:AUTHORITATIVE' + FROM finding_catalog_items item WHERE item.id=$1::uuid`,[created.id]); + } + for(const mapping of SOURCE.installationFindings){ + const family=installationIds.get(mapping.instalacion_id); const finding=findingIds.get(mapping.hallazgo_id); + if(!family||!finding)throw new Error('Invalid installation finding mapping'); + await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]); + } + for(const mapping of SOURCE.subinstallationFindings){ + const family=subIds.get(mapping.subinstalacion_id); const finding=findingIds.get(mapping.hallazgo_id); + if(!family||!finding)throw new Error('Invalid subinstallation finding mapping'); + await q.query(`INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id) VALUES($1::uuid,$2::uuid)`,[finding,family]); + } + } + + private async installFamilyFindingSync(q:QueryRunner):Promise{ + await q.query(` + CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + DELETE FROM finding_catalog_asset_overrides WHERE asset_id=NEW.id AND reason LIKE 'AUTHORITATIVE familia técnica:%'; + IF NEW.inventory_family_id IS NOT NULL THEN + INSERT INTO finding_catalog_asset_overrides(asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by) + SELECT NEW.id,m.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',NEW.created_by,NEW.updated_by + FROM finding_catalog_item_inventory_families m WHERE m.inventory_family_id=NEW.inventory_family_id + ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP; + END IF; + RETURN NEW; + END $$ + `); + await q.query(` + CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF TG_OP='DELETE' THEN + DELETE FROM finding_catalog_asset_overrides o USING assets a + WHERE o.asset_id=a.id AND a.inventory_family_id=OLD.inventory_family_id AND o.catalog_item_id=OLD.catalog_item_id AND o.reason LIKE 'AUTHORITATIVE familia técnica:%'; + RETURN OLD; + END IF; + INSERT INTO finding_catalog_asset_overrides(asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by) + SELECT a.id,NEW.catalog_item_id,true,'AUTHORITATIVE familia técnica: catálogo contextual automático',a.created_by,a.updated_by + FROM assets a WHERE a.inventory_family_id=NEW.inventory_family_id + ON CONFLICT(asset_id,catalog_item_id) DO UPDATE SET is_enabled=true,reason='AUTHORITATIVE familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP; + RETURN NEW; + END $$ + `); + } + + private async asset(q:QueryRunner,typeId:string,parentId:string|null,areaId:string|null,companyId:string|null,concessionId:string|null, + code:string,name:string,sourceName:string,sourceReference:string):Promise{ + const [row]=(await q.query(`INSERT INTO assets(asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id, + code,name,information_status,operational_status,data_origin,source_name,source_reference,is_inventory_instance) + VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,NULL,$6,$7,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$8,$9,false) RETURNING id`, + [typeId,parentId,areaId,companyId,concessionId,code,name,sourceName,sourceReference])) as IdRow[]; + if(!row?.id)throw new Error(`Could not seed ${code}`); 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]); + } + + private async type(q:QueryRunner,sql:string):Promise{ + const rows=(await q.query(sql)) as IdRow[]; if(!rows[0]?.id)throw new Error(`Missing canonical asset type: ${sql}`); return rows[0].id; + } + + private async verify(q:QueryRunner):Promise{ + const [row]=await q.query(`SELECT + (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 fields, + (SELECT COUNT(*) FROM concession_types)::integer concessions, + (SELECT COUNT(*) FROM inventory_families WHERE level='INSTALLATION')::integer installation_families, + (SELECT COUNT(*) FROM inventory_families WHERE level='SUBINSTALLATION')::integer subinstallation_families, + (SELECT COUNT(*) FROM finding_catalog_items)::integer findings, + (SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='INSTALLATION')::integer installation_mappings, + (SELECT COUNT(*) FROM finding_catalog_item_inventory_families m JOIN inventory_families f ON f.id=m.inventory_family_id WHERE f.level='SUBINSTALLATION')::integer subinstallation_mappings, + (SELECT COUNT(*) FROM assets y JOIN asset_types t ON t.id=y.asset_type_id JOIN assets a ON a.id=y.parent_id WHERE lower(t.code)='yacimiento' AND (y.operational_area_id IS DISTINCT FROM a.id OR y.operator_company_id IS NULL OR y.concession_type_id IS NULL))::integer invalid_fields`); + const expected:Record={companies:13,departments:7,areas:64,fields:230,concessions:2,installation_families:14,subinstallation_families:109,findings:181,installation_mappings:48,subinstallation_mappings:880,invalid_fields:0}; + for(const [key,value] of Object.entries(expected)) if(Number(row?.[key]??-1)!==value)throw new Error(`Authoritative inventory verification failed: ${key}=${row?.[key]} expected=${value}`); + } +}