From f4cd931d5172a4e2e577f65382eaebb0a59e980e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Tue, 8 Sep 2026 19:57:18 -0300 Subject: [PATCH] fix(inventory): preserve non-hierarchical operator snapshot compatibility --- ...00-f5-operational-context-compatibility.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts 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 new file mode 100644 index 0000000..2643f5c --- /dev/null +++ b/api-v3/src/database/migrations/1790087250000-f5-operational-context-compatibility.ts @@ -0,0 +1,131 @@ +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. + */ +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 { + 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 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 $$; + `); + } +}