From d89ad8ca51dd7f3628c87510446087763993f0fb Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 08:02:06 -0300 Subject: [PATCH] =?UTF-8?q?F5.1:=20usar=20timestamp=20=C3=BAnico=20para=20?= =?UTF-8?q?reset=20manual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...90087400000-f5-1-clean-manual-inventory.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 api-v3/src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts diff --git a/api-v3/src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts b/api-v3/src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts new file mode 100644 index 0000000..f73441f --- /dev/null +++ b/api-v3/src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts @@ -0,0 +1,154 @@ +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 $$; + `); + + // 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.'); + } +}