diff --git a/api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts b/api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts index 2643f5c..db0032a 100644 --- a/api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts +++ b/api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts @@ -1,22 +1,92 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; /** - * F5 keeps operator_company_id only as a compatibility/current-context snapshot. - * It is NOT part of the physical hierarchy; Area↔Company temporal truth lives in - * area_company_relations. Runtime membership is resolved by Area ancestry. + * F5 makes the physical hierarchy Area-owned. Empresa is never a parent nor a + * required property of Yacimiento/Instalación/Subinstalación. The current and + * historical operator/concession truth lives in area_company_relations and is + * frozen separately by each Inspección/Acta. + * + * operator_company_id is tolerated only on pre-F5 rows for backwards-readable + * history. New assignments or changes are rejected by the DB guard. */ export class F5OperationalContextCompatibility1790087250000 implements MigrationInterface { name = 'F5OperationalContextCompatibility1790087250000'; public async up(queryRunner: QueryRunner): Promise { - await this.installCompatibilityGuard(queryRunner); - } - - public async down(queryRunner: QueryRunner): Promise { await this.installAreaOwnedGuard(queryRunner); } - private async installCompatibilityGuard(queryRunner: QueryRunner): Promise { + public async down(queryRunner: QueryRunner): Promise { + await this.installLegacyPairedGuard(queryRunner); + } + + private async installAreaOwnedGuard(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 de Inventario'; + END IF; + RETURN NEW; + END IF; + + -- Historical rows may still contain the old operator snapshot. It remains + -- readable, but F5 never creates or changes that ownership-like value. + 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 parent.id,parent.parent_id + FROM assets parent + JOIN ancestors child ON parent.id=child.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 $$; + `); + } + + /** Restores the production F4-era paired Area+Empresa guard on rollback. */ + private async installLegacyPairedGuard(queryRunner: QueryRunner): Promise { await queryRunner.query(` CREATE OR REPLACE FUNCTION enforce_asset_operational_context() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -59,12 +129,12 @@ export class F5OperationalContextCompatibility1790087250000 implements Migration 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 + SELECT relation.id INTO active_relation_id + FROM area_company_relations relation + WHERE relation.area_id=NEW.operational_area_id + AND relation.company_id=NEW.operator_company_id + AND relation.relation_role='OPERATOR'::area_organization_role + AND relation.valid_until IS NULL FOR KEY SHARE; IF active_relation_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514', @@ -75,7 +145,9 @@ export class F5OperationalContextCompatibility1790087250000 implements Migration 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 parent.id,parent.parent_id + FROM assets parent + JOIN ancestors child ON parent.id=child.parent_id ) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1 ) THEN @@ -86,46 +158,4 @@ export class F5OperationalContextCompatibility1790087250000 implements Migration END $$; `); } - - private async installAreaOwnedGuard(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 $$; - `); - } }