diff --git a/api-v3/src/database/migrations/1790091000000-f6-solid-inventory-model.ts b/api-v3/src/database/migrations/1790091000000-f6-solid-inventory-model.ts new file mode 100644 index 0000000..0a63dad --- /dev/null +++ b/api-v3/src/database/migrations/1790091000000-f6-solid-inventory-model.ts @@ -0,0 +1,213 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F6SolidInventoryModel1790091000000 implements MigrationInterface { + name = 'F6SolidInventoryModel1790091000000'; + + public async up(queryRunner: QueryRunner): Promise { + // A technical Subinstallation family may be valid under several Installation + // families. F3.1 used child_family_id as the PK, which made this relation + // accidentally one-to-one from the child's point of view. + await queryRunner.query(` + DO $$ + DECLARE constraint_name text; + BEGIN + SELECT con.conname INTO constraint_name + FROM pg_constraint con + JOIN pg_class rel ON rel.oid=con.conrelid + WHERE rel.relname='inventory_family_parent_rules' AND con.contype='p' + LIMIT 1; + IF constraint_name IS NOT NULL THEN + EXECUTE format('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT %I',constraint_name); + END IF; + END $$; + `); + await queryRunner.query(` + ALTER TABLE inventory_family_parent_rules + ADD CONSTRAINT pk_inventory_family_parent_rules + PRIMARY KEY (child_family_id,parent_family_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_inventory_family_parent_rules_parent + ON inventory_family_parent_rules(parent_family_id,child_family_id) + `); + + // Protect compatibility semantics at database level. + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_inventory_family_compatibility_rule() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE child_level text; parent_level text; + BEGIN + SELECT level::text INTO child_level FROM inventory_families WHERE id=NEW.child_family_id; + SELECT level::text INTO parent_level FROM inventory_families WHERE id=NEW.parent_family_id; + IF child_level IS DISTINCT FROM 'SUBINSTALLATION' OR parent_level IS DISTINCT FROM 'INSTALLATION' THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La compatibilidad debe vincular Subinstalación con Instalación'; + END IF; + RETURN NEW; + END $$; + `); + await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules'); + await queryRunner.query(` + CREATE TRIGGER trg_inventory_family_compatibility_rule + BEFORE INSERT OR UPDATE OF child_family_id,parent_family_id ON inventory_family_parent_rules + FOR EACH ROW EXECUTE FUNCTION enforce_inventory_family_compatibility_rule() + `); + + // Family-specific technical fields. Generic asset type attributes remain for + // data genuinely shared by a whole structural level. + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS inventory_family_attribute_definitions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_family_id uuid NOT NULL REFERENCES inventory_families(id) ON DELETE CASCADE, + code varchar(80) NOT NULL, + name varchar(160) NOT NULL, + data_type asset_attribute_data_type NOT NULL, + is_required boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + unit varchar(40) NULL, + options jsonb NULL, + sort_order integer NOT NULL DEFAULT 0, + created_by uuid NULL, + updated_by uuid NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_inventory_family_attribute_code UNIQUE(inventory_family_id,code), + CONSTRAINT chk_inventory_family_attribute_code CHECK (code ~ '^[a-z][a-z0-9_]*$'), + CONSTRAINT chk_inventory_family_attribute_options CHECK ( + (data_type='SELECT' AND options IS NOT NULL AND jsonb_typeof(options)='array') + OR (data_type<>'SELECT') + ) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_inventory_family_attributes_family + ON inventory_family_attribute_definitions(inventory_family_id,is_active,sort_order,name) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS asset_inventory_attribute_values ( + asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + definition_id uuid NOT NULL REFERENCES inventory_family_attribute_definitions(id) ON DELETE CASCADE, + value jsonb NOT NULL, + updated_by uuid NULL, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(asset_id,definition_id) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_asset_inventory_attribute_values_definition + ON asset_inventory_attribute_values(definition_id,asset_id) + `); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_asset_inventory_attribute_family() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE asset_family uuid; definition_family uuid; + BEGIN + SELECT inventory_family_id INTO asset_family FROM assets WHERE id=NEW.asset_id; + SELECT inventory_family_id INTO definition_family + FROM inventory_family_attribute_definitions WHERE id=NEW.definition_id AND is_active=true; + IF asset_family IS NULL OR definition_family IS NULL OR asset_family<>definition_family THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El campo técnico no pertenece a la clasificación del Inventario'; + END IF; + RETURN NEW; + END $$; + `); + await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values'); + await queryRunner.query(` + CREATE TRIGGER trg_asset_inventory_attribute_family + BEFORE INSERT OR UPDATE OF asset_id,definition_id ON asset_inventory_attribute_values + FOR EACH ROW EXECUTE FUNCTION enforce_asset_inventory_attribute_family() + `); + + // Enforce classification level and Subinstallation compatibility on every + // physical Inventory write, not just through the current WEB/API. + await queryRunner.query(` + CREATE OR REPLACE FUNCTION enforce_f6_asset_family_contract() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE child_type text; family_level text; parent_family uuid; + BEGIN + SELECT lower(code) INTO child_type FROM asset_types WHERE id=NEW.asset_type_id; + + IF child_type IN ('departamento','area','yacimiento') THEN + IF NEW.inventory_family_id IS NOT NULL THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento, Área y Yacimiento no llevan clasificación técnica'; + END IF; + RETURN NEW; + END IF; + + IF child_type='instalacion' THEN + IF NEW.inventory_family_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación requiere clasificación técnica'; + END IF; + SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true; + IF family_level IS DISTINCT FROM 'INSTALLATION' THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Instalación'; + END IF; + RETURN NEW; + END IF; + + IF child_type='subinstalacion' THEN + IF NEW.inventory_family_id IS NULL OR NEW.parent_id IS NULL THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere Instalación padre y clasificación técnica'; + END IF; + SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true; + SELECT inventory_family_id INTO parent_family FROM assets WHERE id=NEW.parent_id; + IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación elegida no corresponde a una Subinstalación'; + END IF; + IF parent_family IS NULL OR NOT EXISTS ( + SELECT 1 FROM inventory_family_parent_rules rule + WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family + ) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La clasificación de Subinstalación no es compatible con la Instalación padre'; + END IF; + END IF; + RETURN NEW; + END $$; + `); + await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets'); + await queryRunner.query(` + CREATE TRIGGER trg_f6_asset_family_contract + BEFORE INSERT OR UPDATE OF asset_type_id,parent_id,inventory_family_id ON assets + FOR EACH ROW EXECUTE FUNCTION enforce_f6_asset_family_contract() + `); + + const [check] = (await queryRunner.query(` + SELECT + (SELECT COUNT(*)::integer FROM pg_constraint c JOIN pg_class r ON r.oid=c.conrelid + WHERE r.relname='inventory_family_parent_rules' AND c.contype='p' + AND pg_get_constraintdef(c.oid) LIKE '%child_family_id, parent_family_id%') AS composite_pk, + to_regclass('inventory_family_attribute_definitions') IS NOT NULL AS family_attributes, + to_regclass('asset_inventory_attribute_values') IS NOT NULL AS family_values + `)) as Array<{ composite_pk:number; family_attributes:boolean; family_values:boolean }>; + if (!check || Number(check.composite_pk)!==1 || !check.family_attributes || !check.family_values) { + throw new Error(`F6 inventory model verification failed: ${JSON.stringify(check ?? {})}`); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP TRIGGER IF EXISTS trg_f6_asset_family_contract ON assets'); + await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f6_asset_family_contract()'); + await queryRunner.query('DROP TRIGGER IF EXISTS trg_asset_inventory_attribute_family ON asset_inventory_attribute_values'); + await queryRunner.query('DROP FUNCTION IF EXISTS enforce_asset_inventory_attribute_family()'); + await queryRunner.query('DROP TABLE IF EXISTS asset_inventory_attribute_values'); + await queryRunner.query('DROP TABLE IF EXISTS inventory_family_attribute_definitions'); + await queryRunner.query('DROP TRIGGER IF EXISTS trg_inventory_family_compatibility_rule ON inventory_family_parent_rules'); + await queryRunner.query('DROP FUNCTION IF EXISTS enforce_inventory_family_compatibility_rule()'); + await queryRunner.query('DROP INDEX IF EXISTS idx_inventory_family_parent_rules_parent'); + + // Old schema allowed only one Installation family per Subinstallation family. + // Keep a deterministic first relation if F6 data must be rolled back. + await queryRunner.query(` + DELETE FROM inventory_family_parent_rules rule + USING inventory_family_parent_rules keep + WHERE rule.child_family_id=keep.child_family_id + AND rule.parent_family_id>keep.parent_family_id + `); + await queryRunner.query('ALTER TABLE inventory_family_parent_rules DROP CONSTRAINT IF EXISTS pk_inventory_family_parent_rules'); + await queryRunner.query(` + ALTER TABLE inventory_family_parent_rules + ADD CONSTRAINT inventory_family_parent_rules_pkey PRIMARY KEY(child_family_id) + `); + } +}