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.
169 lines
7.5 KiB
TypeScript
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.');
|
|
}
|
|
}
|