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. // A FIELD_SURVEY+DRAFT row may exist momentarily without family because the // field-discovery transaction creates the provisional record before the F6 // structure service assigns the family selected by the inspector. Such a row // remains unusable for findings until classification is present. 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 IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN RETURN NEW; END IF; 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.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalación requiere una Instalación padre'; END IF; IF NEW.inventory_family_id IS NULL THEN IF NEW.data_origin='FIELD_SURVEY' AND NEW.information_status='DRAFT' THEN RETURN NEW; END IF; RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Subinstalació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; 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) `); } }