diff --git a/api-v3/src/database/migrations/1790087150000-f5-canonical-inventory-hierarchy.ts b/api-v3/src/database/migrations/1790087150000-f5-canonical-inventory-hierarchy.ts new file mode 100644 index 0000000..b134a9e --- /dev/null +++ b/api-v3/src/database/migrations/1790087150000-f5-canonical-inventory-hierarchy.ts @@ -0,0 +1,192 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +type TypeRow = { + id: string; + code: string; + role: string; + active: boolean; + canBeRoot: boolean; +}; + +type RuleRow = { + childTypeId: string; + parentTypeId: string; +}; + +type CountRow = { total: number }; + +const CREATED_TYPES_TABLE = 'f5_canonical_hierarchy_created_types'; +const CREATED_RULES_TABLE = 'f5_canonical_hierarchy_created_rules'; + +const CANONICAL_TYPES = [ + { + code: 'yacimiento', + name: 'Yacimiento', + description: 'Yacimiento perteneciente a un Área.', + }, + { + code: 'instalacion', + name: 'Instalación', + description: 'Instancia física de una Instalación dentro de un Yacimiento.', + }, + { + code: 'subinstalacion', + name: 'Subinstalación', + description: 'Instancia física subordinada a una Instalación.', + }, +] as const; + +const CANONICAL_RULES = [ + ['yacimiento', 'area'], + ['instalacion', 'yacimiento'], + ['subinstalacion', 'instalacion'], +] as const; + +export class F5CanonicalInventoryHierarchy1790087150000 implements MigrationInterface { + name = 'F5CanonicalInventoryHierarchy1790087150000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE ${CREATED_TYPES_TABLE} ( + type_id uuid PRIMARY KEY, + code varchar(80) NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_f5_canonical_created_type FOREIGN KEY (type_id) + REFERENCES asset_types(id) ON DELETE CASCADE + ) + `); + await queryRunner.query(` + CREATE TABLE ${CREATED_RULES_TABLE} ( + child_type_id uuid NOT NULL, + parent_type_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (child_type_id,parent_type_id), + CONSTRAINT fk_f5_canonical_created_rule_child FOREIGN KEY (child_type_id) + REFERENCES asset_types(id) ON DELETE CASCADE, + CONSTRAINT fk_f5_canonical_created_rule_parent FOREIGN KEY (parent_type_id) + REFERENCES asset_types(id) ON DELETE CASCADE + ) + `); + + const area = await this.requireType(queryRunner, 'area'); + if (area.role !== 'AREA' || !area.active || !area.canBeRoot) { + throw new Error('F5 requires canonical active root type area with AREA operational role'); + } + + for (const definition of CANONICAL_TYPES) { + const inserted = (await queryRunner.query(` + INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role) + SELECT $1::varchar,$2::varchar,$3::text,false,true,'GENERIC' + WHERE NOT EXISTS ( + SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar) + ) + RETURNING id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot" + `, [definition.code, definition.name, definition.description])) as TypeRow[]; + + if (inserted[0]?.id) { + await queryRunner.query(` + INSERT INTO ${CREATED_TYPES_TABLE}(type_id,code) + VALUES ($1::uuid,$2::varchar) + `, [inserted[0].id, definition.code]); + } + + const type = await this.requireType(queryRunner, definition.code); + if (type.role !== 'GENERIC' || !type.active || type.canBeRoot) { + throw new Error(`F5 incompatible canonical type configuration: ${definition.code}`); + } + } + + for (const [childCode, parentCode] of CANONICAL_RULES) { + const inserted = (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)=lower($1::varchar) + AND lower(parent.code)=lower($2::varchar) + AND NOT EXISTS ( + SELECT 1 FROM asset_type_parent_rules existing + WHERE existing.child_type_id=child.id AND existing.parent_type_id=parent.id + ) + RETURNING child_type_id AS "childTypeId",parent_type_id AS "parentTypeId" + `, [childCode, parentCode])) as RuleRow[]; + + if (inserted[0]?.childTypeId && inserted[0]?.parentTypeId) { + await queryRunner.query(` + INSERT INTO ${CREATED_RULES_TABLE}(child_type_id,parent_type_id) + VALUES ($1::uuid,$2::uuid) + `, [inserted[0].childTypeId, inserted[0].parentTypeId]); + } + } + + const [verified] = (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(verified?.total ?? 0) !== 3) { + throw new Error(`F5 canonical hierarchy verification failed: rules=${Number(verified?.total ?? 0)}`); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const [usedCreatedTypes] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM assets asset + JOIN ${CREATED_TYPES_TABLE} owned ON owned.type_id=asset.asset_type_id + `)) as CountRow[]; + if (Number(usedCreatedTypes?.total ?? 0) !== 0) { + throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type is already used by Inventory'); + } + + const [foreignRules] = (await queryRunner.query(` + SELECT COUNT(*)::integer AS total + FROM asset_type_parent_rules rule + WHERE ( + rule.child_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE}) + OR rule.parent_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE}) + ) + AND NOT EXISTS ( + SELECT 1 FROM ${CREATED_RULES_TABLE} owned + WHERE owned.child_type_id=rule.child_type_id + AND owned.parent_type_id=rule.parent_type_id + ) + `)) as CountRow[]; + if (Number(foreignRules?.total ?? 0) !== 0) { + throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type gained external parent rules'); + } + + await queryRunner.query(` + DELETE FROM asset_type_parent_rules rule + USING ${CREATED_RULES_TABLE} owned + WHERE rule.child_type_id=owned.child_type_id + AND rule.parent_type_id=owned.parent_type_id + `); + + await queryRunner.query(` + DELETE FROM asset_types type + USING ${CREATED_TYPES_TABLE} owned + WHERE type.id=owned.type_id + `); + + await queryRunner.query(`DROP TABLE ${CREATED_RULES_TABLE}`); + await queryRunner.query(`DROP TABLE ${CREATED_TYPES_TABLE}`); + } + + private async requireType(queryRunner: QueryRunner, code: string): Promise { + const rows = (await queryRunner.query(` + SELECT id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot" + FROM asset_types + WHERE lower(code)=lower($1::varchar) + ORDER BY created_at + `, [code])) as TypeRow[]; + + if (rows.length !== 1) { + throw new Error(`F5 requires exactly one canonical asset type ${code}; found ${rows.length}`); + } + return rows[0]; + } +}