Files
dh-inspeccion-v2/api-v3/src/database/migrations/1790087400000-f5-1-clean-manual-inventory.ts
T
admin 7338f277ad F5.1 · Inventario limpio, jerarquía manual y Hallazgos filtrados (#28)
Reset controlado de datos operativos e históricos, nueva jerarquía manual Departamento → Área → Yacimiento → Instalación → Subinstalación, filtros y administración inline de Hallazgos, y corrección de alta con Área sin Operadora.
2026-09-09 08:55:01 -03:00

169 lines
7.5 KiB
TypeScript

import { MigrationInterface, QueryRunner } from 'typeorm';
export class F51CleanManualInventory1790087400000 implements MigrationInterface {
name = 'F51CleanManualInventory1790087400000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 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<void> {
throw new Error('F5.1 is an intentional destructive clean-start migration. Restore the pre-deploy database backup to recover previous data.');
}
}