import { MigrationInterface, QueryRunner } from 'typeorm'; export class F51CleanManualInventory1790087400000 implements MigrationInterface { name = 'F51CleanManualInventory1790087400000'; public async up(queryRunner: QueryRunner): Promise { // F5.1 is an intentional clean-start cut. The deployment process creates a // full database backup before migrations, so old domain data is recovered // from that backup rather than by pretending a destructive migration can // reconstruct historical rows. await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets'); // Remove all operational/domain instances and every table that depends on // them (visits, acts, findings, reports, versions, media, relations, etc.). // Users/roles/permissions and technical configuration are deliberately not // part of this TRUNCATE. await queryRunner.query('TRUNCATE TABLE assets CASCADE'); // Imported territorial/source material must not silently repopulate or // influence the new manually curated structure. await queryRunner.query('TRUNCATE TABLE administrative_departments CASCADE'); await queryRunner.query('TRUNCATE TABLE source_documents CASCADE'); // Start the classification ↔ finding applicability review from zero while // preserving both master catalogs themselves. await queryRunner.query('TRUNCATE TABLE finding_catalog_item_inventory_families'); // Explicitly clear audit history, including authentication/admin events // accumulated during development. New events continue to be recorded after // this migration. await queryRunner.query('TRUNCATE TABLE audit_events'); // Import/reconciliation tables can contain rows not connected to a current // Asset. Clear every asset_import_* data table without coupling this cut to // one historical import implementation. await queryRunner.query(` DO $$ DECLARE table_name text; BEGIN FOR table_name IN SELECT tablename FROM pg_tables WHERE schemaname = current_schema() AND tablename LIKE 'asset_import_%' LOOP EXECUTE format('TRUNCATE TABLE %I CASCADE', table_name); END LOOP; END $$; `); // F5.1 decouples the physical Inventory tree from Empresa. A structural // Asset may therefore inherit an Area while no operator has been assigned // yet. Keep the useful invariant that an operator can never exist without // an Area, but remove the old all-or-nothing pair requirement. await queryRunner.query(` ALTER TABLE asset_context_history DROP CONSTRAINT IF EXISTS chk_asset_context_history_context_pair `); await queryRunner.query(` ALTER TABLE asset_context_history ADD CONSTRAINT chk_asset_context_history_context_pair CHECK (operator_company_id IS NULL OR operational_area_id IS NOT NULL) `); // Departamento becomes the real root of the physical Inventory tree. await queryRunner.query(` INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role) SELECT 'departamento','Departamento','Departamento administrativo que contiene Áreas.',true,true,'GENERIC' WHERE NOT EXISTS ( SELECT 1 FROM asset_types WHERE lower(code)='departamento' ) `); await queryRunner.query(` UPDATE asset_types SET name='Departamento', description='Departamento administrativo que contiene Áreas.', can_be_root=true, is_active=true, operational_role='GENERIC', updated_at=CURRENT_TIMESTAMP WHERE lower(code)='departamento' `); await queryRunner.query(` UPDATE asset_types SET can_be_root=false,updated_at=CURRENT_TIMESTAMP WHERE lower(code)='area' `); // Area has exactly one canonical structural parent kind: Departamento. await queryRunner.query(` DELETE FROM asset_type_parent_rules rule USING asset_types child WHERE rule.child_type_id=child.id AND lower(child.code)='area' `); 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)='area' AND lower(parent.code)='departamento' ON CONFLICT (child_type_id,parent_type_id) DO NOTHING `); // Database-level guard: UI/API bugs cannot create an invalid physical tree. await queryRunner.query(` CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE child_code text; parent_code text; BEGIN SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id; IF child_code IN ('empresa','organizacion','departamento') THEN IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Departamento son maestros raíz independientes'; END IF; RETURN NEW; END IF; IF child_code NOT IN ('area','yacimiento','instalacion','subinstalacion') THEN RETURN NEW; END IF; IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La estructura requiere Departamento → Área → Yacimiento → Instalación → Subinstalación'; END IF; SELECT lower(type.code) INTO parent_code FROM assets parent JOIN asset_types type ON type.id=parent.asset_type_id WHERE parent.id=NEW.parent_id; IF (child_code='area' AND parent_code<>'departamento') OR (child_code='yacimiento' AND parent_code<>'area') OR (child_code='instalacion' AND parent_code<>'yacimiento') OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía inválida: Departamento → Área → Yacimiento → Instalación → Subinstalación'; END IF; RETURN NEW; END $$; `); await queryRunner.query(` CREATE TRIGGER trg_f5_canonical_asset_hierarchy BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy() `); const rows = (await queryRunner.query(` SELECT (SELECT COUNT(*)::integer FROM assets) AS assets, (SELECT COUNT(*)::integer FROM audit_events) AS audits, (SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families) AS applicability, (SELECT COUNT(*)::integer FROM asset_types WHERE lower(code)='departamento' AND can_be_root=true AND is_active=true) AS departments, (SELECT COUNT(*)::integer 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)='area' AND lower(parent.code)='departamento') AS area_rules `)) as Array<{ assets: number; audits: number; applicability: number; departments: number; area_rules: number }>; const check = rows[0]; if (!check || Number(check.assets) !== 0 || Number(check.audits) !== 0 || Number(check.applicability) !== 0 || Number(check.departments) !== 1 || Number(check.area_rules) !== 1) { throw new Error(`F5.1 clean-start verification failed: ${JSON.stringify(check ?? {})}`); } } public async down(): Promise { throw new Error('F5.1 is an intentional destructive clean-start migration. Restore the pre-deploy database backup to recover previous data.'); } }