From 218f792f1390e5d3319438d8a9ccac3d5ff1e589 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Tue, 8 Sep 2026 19:54:00 -0300 Subject: [PATCH] feat(inventory): preload authoritative territory model --- ...790087200000-f5-authoritative-territory.ts | 579 ++++++++++++++++++ 1 file changed, 579 insertions(+) create mode 100644 api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts diff --git a/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts b/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts new file mode 100644 index 0000000..44f7c92 --- /dev/null +++ b/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts @@ -0,0 +1,579 @@ +import { createHash } from 'node:crypto'; +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { + loadF5InventoryAuthoritativeSource, +} from '../../reference-data/f5-authoritative-inventory-source'; + +type IdRow = { id: string }; + +const TERRITORY_DOCUMENT_NUMBER = 'DH-F5-TERRITORY'; +const TERRITORY_SOURCE_NAME = 'Tablas de yacimiento y areas.xlsx'; + +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}`); + } + + await this.ensureCanonicalTypes(queryRunner); + await this.installHierarchyGuard(queryRunner); + await this.installOperationalContextGuard(queryRunner); + + 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 + ) + ON CONFLICT (document_number,issuer) WHERE document_number IS NOT NULL AND issuer IS NOT NULL + DO UPDATE SET + title=EXCLUDED.title, + external_reference=EXCLUDED.external_reference, + notes=EXCLUDED.notes, + updated_at=CURRENT_TIMESTAMP + `, [ + TERRITORY_DOCUMENT_NUMBER, + TERRITORY_SOURCE_NAME, + `sha256:${source.areaSource.sha256}`, + `F5 · fuente territorial autorizada · hoja ${source.areaSource.sheet} · ${rows.length} filas`, + ]); + const sourceDocumentId = await this.id( + queryRunner, + `SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`, + [TERRITORY_DOCUMENT_NUMBER], + 'territory source document', + ); + + const companyTypeId = await this.typeId(queryRunner, 'empresa'); + const areaTypeId = await this.typeId(queryRunner, 'area'); + const fieldTypeId = await this.typeId(queryRunner, 'yacimiento'); + + 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 UPDATE SET + legal_name=EXCLUDED.legal_name, + updated_at=CURRENT_TIMESTAMP + `, [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); + 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) + ON CONFLICT (province_code,normalized_name) DO UPDATE SET + name=EXCLUDED.name, + is_active=true, + source_document_id=EXCLUDED.source_document_id, + updated_at=CURRENT_TIMESTAMP + `, [code('F5-DEP', normalized, 10), departmentName, normalized, sourceDocumentId]); + const departmentId = await this.id( + queryRunner, + `SELECT id FROM administrative_departments WHERE province_code='MENDOZA' AND normalized_name=$1 LIMIT 1`, + [normalized], + `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 queryRunner.query(` + UPDATE area_department_relations + SET valid_until=CURRENT_DATE, + notes=concat_ws(E'\n',notes,'F5: reemplazada por fuente territorial autorizada'), + updated_at=CURRENT_TIMESTAMP + WHERE area_id=$1::uuid + AND valid_until IS NULL + AND department_id<>$2::uuid + `, [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 operatorKey = key(areaRow.empresaOperadora); + const operatorId = companyIds.get(operatorKey) ?? null; + await queryRunner.query(` + UPDATE area_company_relations + SET valid_until=CURRENT_TIMESTAMP, + end_reason='F5: reemplazada por fuente territorial autorizada', + updated_at=CURRENT_TIMESTAMP + WHERE area_id=$1::uuid + AND relation_role='OPERATOR' + AND valid_until IS NULL + AND ($2::uuid IS NULL OR company_id<>$2::uuid) + `, [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,'ACTIVE',$4::uuid,$5 + WHERE NOT EXISTS ( + SELECT 1 FROM area_legal_rights + WHERE area_id=$1::uuid AND source_document_id=$4::uuid AND name=$3 + ) + `, [ + 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 lower(btrim(asset.name))=lower(btrim($2)) + ORDER BY (asset.information_status<>'INACTIVE') DESC,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; + } else { + await queryRunner.query(` + UPDATE assets + SET parent_id=$2::uuid, + operator_company_id=NULL, + is_inventory_instance=false, + information_status=CASE WHEN information_status='INACTIVE' THEN 'VALIDATED' ELSE information_status END, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1::uuid + `, [yacimientoId, areaId]); + } + 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(`DELETE FROM area_legal_rights WHERE source_document_id=$1::uuid`, [sourceDocumentId]); + 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 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 assets + WHERE source_reference LIKE 'F5:TERRITORY:AREA:%' + AND NOT EXISTS (SELECT 1 FROM assets child WHERE child.parent_id=assets.id) + `); + 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:%' + AND NOT EXISTS ( + SELECT 1 FROM area_company_relations relation + WHERE relation.company_id=assets.id + ) + `); + 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 this.restoreLegacyOperationalContextGuard(queryRunner); + } + + private async ensureCanonicalTypes(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE asset_types + SET name='Empresa',description='Organización independiente. Su relación con un Área es temporal y no forma parte de la jerarquía física.', + can_be_root=true,is_active=true,operational_role='COMPANY',updated_at=CURRENT_TIMESTAMP + WHERE lower(code) IN ('empresa','organizacion') + `); + await queryRunner.query(` + INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role) + SELECT 'empresa','Empresa','Organización independiente. Su relación con un Área es temporal y no forma parte de la jerarquía física.',true,true,'COMPANY' + WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE operational_role='COMPANY') + `); + await queryRunner.query(` + UPDATE asset_types + SET name='Área',description='Ancla territorial independiente de la Empresa operadora.', + can_be_root=true,is_active=true,operational_role='AREA',updated_at=CURRENT_TIMESTAMP + WHERE lower(code)='area' + `); + await queryRunner.query(` + INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role) + SELECT 'area','Área','Ancla territorial independiente de la Empresa operadora.',true,true,'AREA' + WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)='area') + `); + 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,$2,$3,false,true,'GENERIC' + WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1)) + `, [typeCode,typeName,description]); + await queryRunner.query(` + UPDATE asset_types + SET name=$2,description=$3,can_be_root=false,is_active=true,operational_role='GENERIC',updated_at=CURRENT_TIMESTAMP + WHERE lower(code)=lower($1) + `, [typeCode,typeName,description]); + } + + for (const [child,parent] of [ + ['yacimiento','area'], + ['instalacion','yacimiento'], + ['subinstalacion','instalacion'], + ]) { + await queryRunner.query(` + INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id) + SELECT child.id,parent.id + FROM asset_types child CROSS JOIN asset_types parent + WHERE lower(child.code)=$1 AND lower(parent.code)=$2 + ON CONFLICT DO NOTHING + `, [child,parent]); + } + } + + 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 installOperationalContextGuard(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_asset_operational_context() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE asset_role asset_type_operational_role; area_role asset_type_operational_role; + BEGIN + SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id; + IF asset_role <> 'GENERIC'::asset_type_operational_role THEN + IF NEW.operational_area_id IS NOT NULL OR NEW.operator_company_id IS NOT NULL THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área y Empresa no reciben contexto operativo'; + END IF; + RETURN NEW; + END IF; + + IF NEW.operator_company_id IS NOT NULL THEN + IF TG_OP='INSERT' OR OLD.operator_company_id IS NULL OR NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa pertenece al contexto temporal del Área/Inspección y no al Inventario'; + END IF; + END IF; + IF NEW.operational_area_id IS NULL THEN RETURN NEW; END IF; + SELECT t.operational_role INTO area_role + FROM assets area JOIN asset_types t ON t.id=area.asset_type_id + WHERE area.id=NEW.operational_area_id + AND area.information_status<>'INACTIVE' AND t.is_active=true; + IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset'; + END IF; + IF NEW.parent_id IS NULL OR NOT EXISTS ( + WITH RECURSIVE ancestors AS ( + SELECT id,parent_id FROM assets WHERE id=NEW.parent_id + UNION ALL + SELECT p.id,p.parent_id FROM assets p JOIN ancestors c ON p.id=c.parent_id + ) + SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1 + ) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy'; + END IF; + RETURN NEW; + END $$; + `); + } + + private async restoreLegacyOperationalContextGuard(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_asset_operational_context() RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE asset_role asset_type_operational_role; area_role asset_type_operational_role; company_role asset_type_operational_role; active_relation_id uuid; + BEGIN + IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN RETURN NEW; END IF; + IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization must be assigned together'; END IF; + SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id; + IF asset_role <> 'GENERIC'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and organization assets cannot receive an operational assignment'; END IF; + SELECT t.operational_role INTO area_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operational_area_id AND a.information_status<>'INACTIVE' AND t.is_active=true; + SELECT t.operational_role INTO company_role FROM assets a JOIN asset_types t ON t.id=a.asset_type_id WHERE a.id=NEW.operator_company_id AND a.information_status<>'INACTIVE' AND t.is_active=true; + IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset'; END IF; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator organization must be an active COMPANY-role asset'; END IF; + SELECT r.id INTO active_relation_id FROM area_company_relations r WHERE r.area_id=NEW.operational_area_id AND r.company_id=NEW.operator_company_id AND r.relation_role='OPERATOR'::area_organization_role AND r.valid_until IS NULL FOR KEY SHARE; + IF active_relation_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization do not have an active OPERATOR relation'; END IF; + IF NEW.parent_id IS NULL OR NOT EXISTS (WITH RECURSIVE ancestors AS (SELECT id,parent_id FROM assets WHERE id=NEW.parent_id UNION ALL SELECT p.id,p.parent_id FROM assets p JOIN ancestors c ON p.id=c.parent_id) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1) THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy'; END IF; + RETURN NEW; + END $$; + `); + } + + 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 lower(btrim(asset.name))=lower(btrim($2)) + ORDER BY (asset.information_status<>'INACTIVE') DESC,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; + } else { + await queryRunner.query(` + UPDATE assets + SET parent_id=NULL,operational_area_id=NULL,operator_company_id=NULL, + is_inventory_instance=false, + information_status=CASE WHEN information_status='INACTIVE' THEN 'VALIDATED' ELSE information_status END, + updated_at=CURRENT_TIMESTAMP + WHERE id=$1::uuid + `, [assetId]); + } + 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 typeId(queryRunner: QueryRunner, typeCode: string): Promise { + return this.id( + queryRunner, + `SELECT id FROM asset_types WHERE lower(code)=lower($1) LIMIT 1`, + [typeCode], + `asset type ${typeCode}`, + ); + } + + 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 rows = (await queryRunner.query(sql,params)) as IdRow[]; + return rows[0]?.id ?? null; + } +}