import { createHash } from 'node:crypto'; import { MigrationInterface, QueryRunner } from 'typeorm'; import { loadF5InventoryAuthoritativeSource } from '../../reference-data/f5-authoritative-inventory-source'; type IdRow = { id: string }; type CountRow = { total: number }; const TERRITORY_DOCUMENT_NUMBER = 'DH-F5-TERRITORY'; const TERRITORY_SOURCE_NAME = 'Tablas de yacimiento y areas.xlsx'; const BACKUP_TABLE = 'f5_territory_relation_backups'; function key(value: string): string { return value.normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .trim() .replace(/\s+/g, ' '); } function code(prefix: string, value: string, length = 12): string { return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`; } function uniqueBy(values: T[], identity: (value: T) => string): T[] { const seen = new Set(); const output: T[] = []; for (const value of values) { const id = identity(value); if (seen.has(id)) continue; seen.add(id); output.push(value); } return output; } export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface { name = 'F5AuthoritativeTerritory1790087200000'; public async up(queryRunner: QueryRunner): Promise { const source = loadF5InventoryAuthoritativeSource(); if ( source.areaSource.file !== TERRITORY_SOURCE_NAME || source.areaSource.sheet !== 'cr26e_tabla1' || source.areaSource.sha256 !== '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78' || source.areaSource.rows.length !== 230 ) { throw new Error('F5 territory source contract mismatch'); } const rows = source.areaSource.rows; const areaRows = uniqueBy(rows, (row) => key(row.area)); const pairRows = uniqueBy(rows, (row) => `${key(row.area)}|${key(row.yacimiento)}`); if (areaRows.length !== 64 || pairRows.length !== 230) { throw new Error(`F5 territory cardinality mismatch: areas=${areaRows.length}, pairs=${pairRows.length}`); } // Every Area must have one unambiguous source context. The workbook is the // only authority for this preload; conflicting rows must abort the migration. for (const areaRow of areaRows) { const sameArea = rows.filter((row) => key(row.area)===key(areaRow.area)); const dimensions = [ new Set(sameArea.map((row) => key(row.departamento))), new Set(sameArea.map((row) => key(row.tipoConcesion))), new Set(sameArea.map((row) => key(row.empresaOperadora))), ]; if (dimensions.some((values) => values.size !== 1)) { throw new Error(`F5 territory source has conflicting Area context: ${areaRow.area}`); } } await this.assertCanonicalTypesAndRules(queryRunner); await this.installHierarchyGuard(queryRunner); await this.ensureBackupTable(queryRunner); const preExistingDocument = await this.optionalId( queryRunner, `SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`, [TERRITORY_DOCUMENT_NUMBER], ); if (preExistingDocument) { throw new Error('F5 territory source document already exists before migration'); } const insertedDocument = (await queryRunner.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_DOCUMENT_NUMBER, TERRITORY_SOURCE_NAME, `sha256:${source.areaSource.sha256}`, `F5 · fuente territorial autorizada · hoja ${source.areaSource.sheet} · ${rows.length} filas`, ])) as IdRow[]; const sourceDocumentId = insertedDocument[0]?.id; if (!sourceDocumentId) throw new Error('F5 could not create territory source document'); const companyTypeId = await this.id( queryRunner, `SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`, [], 'COMPANY asset type', ); const areaTypeId = await this.id( queryRunner, `SELECT id FROM asset_types WHERE operational_role='AREA' AND is_active=true ORDER BY (lower(code)='area') DESC,created_at LIMIT 1`, [], 'AREA asset type', ); const fieldTypeId = await this.id( queryRunner, `SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`, [], 'Yacimiento asset type', ); const companyNames = [...new Set(rows.map((row) => row.empresaOperadora.trim()))] .filter((name) => name && key(name) !== key('Sin Empresa Operadora')) .sort((a, b) => a.localeCompare(b, 'es')); const companyIds = new Map(); for (const companyName of companyNames) { const companyId = await this.ensureRootAsset(queryRunner, { typeId: companyTypeId, role: 'COMPANY', code: code('F5-ORG', key(companyName)), name: companyName, sourceDocumentId, sourceReference: `F5:TERRITORY:COMPANY:${code('SRC', key(companyName), 10)}`, }); companyIds.set(key(companyName), companyId); await queryRunner.query(` INSERT INTO organization_profiles (asset_id,organization_kind,legal_name) VALUES ($1::uuid,$2::organization_kind,$3) ON CONFLICT (asset_id) DO NOTHING `, [companyId, companyName.trim().toUpperCase().startsWith('UTE (') ? 'UTE' : 'COMPANY', companyName]); } const departmentIds = new Map(); for (const departmentName of [...new Set(rows.map((row) => row.departamento.trim()))].sort((a,b)=>a.localeCompare(b,'es'))) { const normalized = key(departmentName); let departmentId = await this.optionalId(queryRunner, ` SELECT id FROM administrative_departments WHERE province_code='MENDOZA' AND normalized_name=$1 AND is_active=true LIMIT 1 `, [normalized]); if (!departmentId) { const inserted = (await queryRunner.query(` INSERT INTO administrative_departments ( province_code,code,name,normalized_name,is_active,source_document_id ) VALUES ('MENDOZA',$1,$2,$3,true,$4::uuid) RETURNING id `, [code('F5-DEP', normalized, 10), departmentName, normalized, sourceDocumentId])) as IdRow[]; departmentId=inserted[0]?.id ?? null; } if (!departmentId) throw new Error(`F5 could not seed department ${departmentName}`); departmentIds.set(normalized, departmentId); } const areaIds = new Map(); for (const areaRow of areaRows) { const areaId = await this.ensureRootAsset(queryRunner, { typeId: areaTypeId, role: 'AREA', code: code('F5-AREA', key(areaRow.area)), name: areaRow.area, sourceDocumentId, sourceReference: `F5:TERRITORY:AREA:${code('SRC', key(areaRow.area), 10)}`, }); areaIds.set(key(areaRow.area), areaId); const departmentId = departmentIds.get(key(areaRow.departamento)); if (!departmentId) throw new Error(`F5 missing department ${areaRow.departamento}`); await this.backupAndCloseDepartmentRelations(queryRunner,areaId,departmentId); await queryRunner.query(` INSERT INTO area_department_relations ( area_id,department_id,valid_from,source_document_id,notes ) SELECT $1::uuid,$2::uuid,CURRENT_DATE,$3::uuid,$4 WHERE NOT EXISTS ( SELECT 1 FROM area_department_relations WHERE area_id=$1::uuid AND department_id=$2::uuid AND valid_until IS NULL ) `, [ areaId, departmentId, sourceDocumentId, `F5 · ${TERRITORY_SOURCE_NAME} · ${source.areaSource.sheet}`, ]); const operatorId = companyIds.get(key(areaRow.empresaOperadora)) ?? null; await this.backupAndCloseOperatorRelations(queryRunner,areaId,operatorId); if (operatorId) { await queryRunner.query(` INSERT INTO area_company_relations ( area_id,company_id,relation_role,source_document_id,valid_from,start_reason ) SELECT $1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4 WHERE NOT EXISTS ( SELECT 1 FROM area_company_relations WHERE area_id=$1::uuid AND company_id=$2::uuid AND relation_role='OPERATOR' AND valid_until IS NULL ) `, [ areaId, operatorId, sourceDocumentId, `F5 · operadora vigente según ${TERRITORY_SOURCE_NAME}`, ]); } const rightType = areaRow.tipoConcesion === 'Exploración' ? 'EXPLORATION_PERMIT' : areaRow.tipoConcesion === 'Explotación' ? 'EXPLOITATION_CONCESSION' : 'OTHER'; const rightName = `${areaRow.tipoConcesion} · ${areaRow.area}`; await queryRunner.query(` INSERT INTO area_legal_rights ( area_id,right_type,name,status,source_document_id,notes ) SELECT $1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5 WHERE NOT EXISTS ( SELECT 1 FROM area_legal_rights WHERE area_id=$1::uuid AND right_type=$2::area_legal_right_type AND lower(btrim(name))=lower(btrim($3::varchar)) AND status IN ('ACTIVE','PENDING') ) `, [ areaId, rightType, rightName, sourceDocumentId, `F5 · tipo de concesión tomado literalmente de ${TERRITORY_SOURCE_NAME}`, ]); } for (const row of pairRows) { const areaId = areaIds.get(key(row.area)); if (!areaId) throw new Error(`F5 missing area ${row.area}`); const sourceReference = `F5:TERRITORY:YAC:${code('SRC', `${key(row.area)}|${key(row.yacimiento)}`, 12)}`; let yacimientoId = await this.optionalId(queryRunner, ` SELECT asset.id FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE lower(type.code)='yacimiento' AND asset.parent_id=$1::uuid AND asset.information_status<>'INACTIVE' AND lower(btrim(asset.name))=lower(btrim($2)) ORDER BY asset.created_at LIMIT 1 `, [areaId, row.yacimiento]); if (!yacimientoId) { const inserted = (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,is_inventory_instance ) VALUES ( $1::uuid,$2::uuid,NULL,NULL,$3,$4,$5,'VALIDATED','UNKNOWN', 'PROVIDED_DOCUMENT',$6,$7,$8,false ) RETURNING id `, [ fieldTypeId, areaId, code('F5-YAC', `${key(row.area)}|${key(row.yacimiento)}`), row.yacimiento, `Yacimiento del Área ${row.area}`, TERRITORY_SOURCE_NAME, sourceReference, `${source.areaSource.sheet} · fila ${row.sourceRow}`, ])) as IdRow[]; yacimientoId = inserted[0]?.id ?? null; } if (!yacimientoId) throw new Error(`F5 could not seed yacimiento ${row.area} / ${row.yacimiento}`); await this.linkSource(queryRunner, yacimientoId, sourceDocumentId, `Hoja ${source.areaSource.sheet} · fila ${row.sourceRow}`); } const [verification] = (await queryRunner.query(` SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA')::integer AS areas, COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')::integer AS yacimientos FROM asset_source_documents link JOIN assets asset ON asset.id=link.asset_id JOIN asset_types type ON type.id=asset.asset_type_id WHERE link.document_id=$1::uuid `, [sourceDocumentId])) as Array<{ areas: number; yacimientos: number }>; if (Number(verification?.areas ?? 0) !== 64 || Number(verification?.yacimientos ?? 0) !== 230) { throw new Error(`F5 territory preload verification failed: ${JSON.stringify(verification ?? {})}`); } } public async down(queryRunner: QueryRunner): Promise { const sourceDocumentId = await this.optionalId( queryRunner, `SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`, [TERRITORY_DOCUMENT_NUMBER], ); if (!sourceDocumentId) { await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets'); await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()'); await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`); return; } await this.assertRelationBackupsUnchanged(queryRunner); await this.assertCreatedMastersUnused(queryRunner,sourceDocumentId); await queryRunner.query(`DELETE FROM area_legal_rights WHERE source_document_id=$1::uuid`,[sourceDocumentId]); // Remove relations created by F5 first so restoring the previously-active // relation cannot violate active-relation uniqueness constraints. await queryRunner.query(`DELETE FROM area_company_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]); await queryRunner.query(`DELETE FROM area_department_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]); await this.restoreRelationBackups(queryRunner); await queryRunner.query(`DELETE FROM asset_source_documents WHERE document_id=$1::uuid`,[sourceDocumentId]); await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:YAC:%'`); await queryRunner.query(`DELETE FROM organization_profiles profile USING assets asset WHERE profile.asset_id=asset.id AND asset.source_reference LIKE 'F5:TERRITORY:COMPANY:%'`); await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:COMPANY:%'`); await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:AREA:%'`); await queryRunner.query(`DELETE FROM administrative_departments WHERE source_document_id=$1::uuid`,[sourceDocumentId]); await queryRunner.query(`DELETE FROM source_documents WHERE id=$1::uuid`,[sourceDocumentId]); await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets'); await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()'); await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`); } private async assertCanonicalTypesAndRules(queryRunner: QueryRunner): Promise { const [roles] = (await queryRunner.query(` SELECT COUNT(*) FILTER (WHERE operational_role='AREA' AND is_active=true)::integer AS areas, COUNT(*) FILTER (WHERE operational_role='COMPANY' AND is_active=true)::integer AS companies FROM asset_types `)) as Array<{areas:number; companies:number}>; if (Number(roles?.areas ?? 0)<1 || Number(roles?.companies ?? 0)<1) { throw new Error('F5 requires active AREA and COMPANY master types'); } for (const [typeCode,typeName,description] of [ ['yacimiento','Yacimiento','Yacimiento perteneciente a un Área.'], ['instalacion','Instalación','Instancia física de una Instalación dentro de un Yacimiento.'], ['subinstalacion','Subinstalación','Instancia física subordinada a una Instalación.'], ] as const) { await queryRunner.query(` INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role) SELECT $1::varchar,$2,$3,false,true,'GENERIC' WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar)) `,[typeCode,typeName,description]); const [type] = (await queryRunner.query(` SELECT operational_role AS role,is_active AS active,can_be_root AS "canBeRoot" FROM asset_types WHERE lower(code)=lower($1) LIMIT 1 `,[typeCode])) as Array<{role:string;active:boolean;canBeRoot:boolean}>; if (!type || type.role!=='GENERIC' || !type.active || type.canBeRoot) { throw new Error(`F5 incompatible master type configuration: ${typeCode}`); } } const [ruleCount] = (await queryRunner.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)='yacimiento' AND lower(parent.code)='area') OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento') OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion') `)) as CountRow[]; if (Number(ruleCount?.total ?? 0)!==3) { throw new Error('F5 requires canonical parent rules Area → Yacimiento → Instalación → Subinstalación'); } } private async installHierarchyGuard(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE child_code text; parent_code text; BEGIN SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id; IF child_code IN ('empresa','organizacion','area') THEN IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Área son maestros raíz independientes'; END IF; RETURN NEW; END IF; IF child_code NOT IN ('yacimiento','instalacion','subinstalacion') THEN RETURN NEW; END IF; IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento, Instalación y Subinstalación requieren padre'; END IF; SELECT lower(type.code) INTO parent_code FROM assets parent JOIN asset_types type ON type.id=parent.asset_type_id WHERE parent.id=NEW.parent_id; IF (child_code='yacimiento' AND parent_code<>'area') OR (child_code='instalacion' AND parent_code<>'yacimiento') OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía F5 inválida: Área → Yacimiento → Instalación → Subinstalación'; END IF; RETURN NEW; END $$; `); await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets'); await queryRunner.query(` CREATE TRIGGER trg_f5_canonical_asset_hierarchy BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy() `); } private async ensureBackupTable(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE TABLE ${BACKUP_TABLE} ( relation_kind varchar(32) NOT NULL, relation_id uuid NOT NULL, previous_values jsonb NOT NULL, applied_values jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (relation_kind,relation_id), CONSTRAINT chk_f5_territory_backup_kind CHECK (relation_kind IN ('AREA_COMPANY','AREA_DEPARTMENT')) ) `); } private async backupAndCloseDepartmentRelations(queryRunner: QueryRunner,areaId:string,departmentId:string):Promise { const marker='F5: reemplazada por fuente territorial autorizada'; await queryRunner.query(` INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values) SELECT 'AREA_DEPARTMENT',relation.id, jsonb_build_object('validUntil',relation.valid_until,'notes',relation.notes), jsonb_build_object('validUntil',CURRENT_DATE,'notes',concat_ws(E'\n',relation.notes,$3::text)) FROM area_department_relations relation WHERE relation.area_id=$1::uuid AND relation.valid_until IS NULL AND relation.department_id<>$2::uuid ON CONFLICT DO NOTHING `,[areaId,departmentId,marker]); await queryRunner.query(` UPDATE area_department_relations relation SET valid_until=(backup.applied_values->>'validUntil')::date, notes=backup.applied_values->>'notes', updated_at=CURRENT_TIMESTAMP FROM ${BACKUP_TABLE} backup WHERE backup.relation_kind='AREA_DEPARTMENT' AND backup.relation_id=relation.id AND relation.area_id=$1::uuid AND relation.valid_until IS NULL `,[areaId]); } private async backupAndCloseOperatorRelations(queryRunner: QueryRunner,areaId:string,operatorId:string|null):Promise { const marker='F5: reemplazada por fuente territorial autorizada'; await queryRunner.query(` INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values) SELECT 'AREA_COMPANY',relation.id, jsonb_build_object('validUntil',relation.valid_until,'endReason',relation.end_reason), jsonb_build_object('validUntil',CURRENT_TIMESTAMP,'endReason',$3::text) FROM area_company_relations relation WHERE relation.area_id=$1::uuid AND relation.relation_role='OPERATOR' AND relation.valid_until IS NULL AND ($2::uuid IS NULL OR relation.company_id<>$2::uuid) ON CONFLICT DO NOTHING `,[areaId,operatorId,marker]); await queryRunner.query(` UPDATE area_company_relations relation SET valid_until=(backup.applied_values->>'validUntil')::timestamptz, end_reason=backup.applied_values->>'endReason', updated_at=CURRENT_TIMESTAMP FROM ${BACKUP_TABLE} backup WHERE backup.relation_kind='AREA_COMPANY' AND backup.relation_id=relation.id AND relation.area_id=$1::uuid AND relation.valid_until IS NULL `,[areaId]); } private async assertRelationBackupsUnchanged(queryRunner: QueryRunner):Promise { const [changed] = (await queryRunner.query(` SELECT COUNT(*)::integer AS total FROM ${BACKUP_TABLE} backup LEFT JOIN area_company_relations company_relation ON backup.relation_kind='AREA_COMPANY' AND company_relation.id=backup.relation_id LEFT JOIN area_department_relations department_relation ON backup.relation_kind='AREA_DEPARTMENT' AND department_relation.id=backup.relation_id WHERE ( backup.relation_kind='AREA_COMPANY' AND ( company_relation.id IS NULL OR company_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::timestamptz OR company_relation.end_reason IS DISTINCT FROM backup.applied_values->>'endReason' ) ) OR ( backup.relation_kind='AREA_DEPARTMENT' AND ( department_relation.id IS NULL OR department_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::date OR department_relation.notes IS DISTINCT FROM backup.applied_values->>'notes' ) ) `)) as CountRow[]; if (Number(changed?.total ?? 0)>0) { throw new Error('Cannot safely rollback F5 territory: a relation closed by the preload was modified afterwards'); } } private async restoreRelationBackups(queryRunner: QueryRunner):Promise { await queryRunner.query(` UPDATE area_company_relations relation SET valid_until=(backup.previous_values->>'validUntil')::timestamptz, end_reason=backup.previous_values->>'endReason', updated_at=CURRENT_TIMESTAMP FROM ${BACKUP_TABLE} backup WHERE backup.relation_kind='AREA_COMPANY' AND backup.relation_id=relation.id `); await queryRunner.query(` UPDATE area_department_relations relation SET valid_until=(backup.previous_values->>'validUntil')::date, notes=backup.previous_values->>'notes', updated_at=CURRENT_TIMESTAMP FROM ${BACKUP_TABLE} backup WHERE backup.relation_kind='AREA_DEPARTMENT' AND backup.relation_id=relation.id `); } private async assertCreatedMastersUnused(queryRunner:QueryRunner,sourceDocumentId:string):Promise { const [used] = (await queryRunner.query(` SELECT COUNT(*)::integer AS total FROM assets asset WHERE asset.source_reference LIKE 'F5:TERRITORY:%' AND ( EXISTS (SELECT 1 FROM assets child WHERE child.parent_id=asset.id AND child.source_reference NOT LIKE 'F5:TERRITORY:%') OR EXISTS (SELECT 1 FROM inspection_visits visit WHERE visit.operational_area_id=asset.id OR visit.operator_company_id=asset.id) OR EXISTS (SELECT 1 FROM inspection_visit_assets link WHERE link.asset_id=asset.id) OR EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.asset_id=asset.id) ) `)) as CountRow[]; if (Number(used?.total ?? 0)>0) { throw new Error('Cannot safely rollback F5 territory: F5-created master data is already used by operational records'); } void sourceDocumentId; } private async ensureRootAsset( queryRunner: QueryRunner, input: { typeId: string; role: 'AREA' | 'COMPANY'; code: string; name: string; sourceDocumentId: string; sourceReference: string; }, ): Promise { let assetId = await this.optionalId(queryRunner, ` SELECT asset.id FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id WHERE type.operational_role=$1::asset_type_operational_role AND asset.information_status<>'INACTIVE' AND lower(btrim(asset.name))=lower(btrim($2)) ORDER BY asset.created_at LIMIT 1 `,[input.role,input.name]); if (!assetId) { const inserted = (await queryRunner.query(` INSERT INTO assets ( asset_type_id,parent_id,operational_area_id,operator_company_id, code,name,information_status,operational_status,data_origin, source_name,source_reference,source_notes,is_inventory_instance ) VALUES ($1::uuid,NULL,NULL,NULL,$2,$3,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$4,$5,$6,false) RETURNING id `,[ input.typeId,input.code,input.name,TERRITORY_SOURCE_NAME,input.sourceReference, 'F5 · fuente territorial autorizada', ])) as IdRow[]; assetId=inserted[0]?.id ?? null; } if (!assetId) throw new Error(`F5 could not seed ${input.role} ${input.name}`); await this.linkSource(queryRunner,assetId,input.sourceDocumentId,'F5 · fuente territorial autorizada'); return assetId; } private async linkSource(queryRunner:QueryRunner,assetId:string,documentId:string,notes:string):Promise { await queryRunner.query(` INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes) VALUES ($1::uuid,$2::uuid,'SOURCE',$3) ON CONFLICT (asset_id,document_id,relation_type) DO UPDATE SET notes=EXCLUDED.notes,updated_at=CURRENT_TIMESTAMP `,[assetId,documentId,notes]); } private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise { const value=await this.optionalId(queryRunner,sql,params); if (!value) throw new Error(`F5 could not resolve ${label}`); return value; } private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise { const result=(await queryRunner.query(sql,params)) as IdRow[]; return result[0]?.id ?? null; } }