diff --git a/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts b/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts index 44f7c92..13eb716 100644 --- a/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts +++ b/api-v3/src/database/migrations/1790087200000-f5-authoritative-territory.ts @@ -1,13 +1,13 @@ import { createHash } from 'node:crypto'; import { MigrationInterface, QueryRunner } from 'typeorm'; -import { - loadF5InventoryAuthoritativeSource, -} from '../../reference-data/f5-authoritative-inventory-source'; +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') @@ -55,39 +55,65 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface 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); + // 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 queryRunner.query(` + 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 - ) - 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 + ) 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`, - ]); - 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', - ); + ])) as IdRow[]; + const sourceDocumentId = insertedDocument[0]?.id; + if (!sourceDocumentId) throw new Error('F5 could not create 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 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')) @@ -106,32 +132,28 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface 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 + 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); - 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}`, - ); + 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); } @@ -149,15 +171,7 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface 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 this.backupAndCloseDepartmentRelations(queryRunner,areaId,departmentId); await queryRunner.query(` INSERT INTO area_department_relations ( area_id,department_id,valid_from,source_document_id,notes @@ -174,19 +188,8 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface `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]); - + const operatorId = companyIds.get(key(areaRow.empresaOperadora)) ?? null; + await this.backupAndCloseOperatorRelations(queryRunner,areaId,operatorId); if (operatorId) { await queryRunner.query(` INSERT INTO area_company_relations ( @@ -219,7 +222,10 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface 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 + WHERE area_id=$1::uuid + AND right_type=$2::area_legal_right_type + AND lower(btrim(name))=lower(btrim($3)) + AND status IN ('ACTIVE','PENDING') ) `, [ areaId, @@ -240,8 +246,9 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface 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.information_status<>'INACTIVE') DESC,asset.created_at + ORDER BY asset.created_at LIMIT 1 `, [areaId, row.yacimiento]); if (!yacimientoId) { @@ -250,12 +257,10 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface 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 ( + ) VALUES ( $1::uuid,$2::uuid,NULL,NULL,$3,$4,$5,'VALIDATED','UNKNOWN', 'PROVIDED_DOCUMENT',$6,$7,$8,false - ) - RETURNING id + ) RETURNING id `, [ fieldTypeId, areaId, @@ -267,16 +272,6 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface `${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}`); @@ -302,62 +297,48 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface `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]); + 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 this.restoreLegacyOperationalContextGuard(queryRunner); + await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`); } - 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') - `); + 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.'], @@ -367,26 +348,27 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface 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]); + `,[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}`); + } } - 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]); + 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'); } } @@ -418,7 +400,7 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface RETURN NEW; END $$; `); - await queryRunner.query(`DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets`); + 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 @@ -426,67 +408,137 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface `); } - private async installOperationalContextGuard(queryRunner: QueryRunner): Promise { + private async ensureBackupTable(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 $$; + 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 restoreLegacyOperationalContextGuard(queryRunner: QueryRunner): Promise { + private async backupAndCloseDepartmentRelations(queryRunner: QueryRunner,areaId:string,departmentId:string):Promise { + const marker='F5: reemplazada por fuente territorial autorizada'; 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 $$; + 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)) + 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) + 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( @@ -505,75 +557,46 @@ export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface 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.information_status<>'INACTIVE') DESC,asset.created_at + ORDER BY asset.created_at LIMIT 1 - `, [input.role,input.name]); + `,[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) + ) 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`, + '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 { + 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 + 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); + 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; + private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise { + const result=(await queryRunner.query(sql,params)) as IdRow[]; + return result[0]?.id ?? null; } }