diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 1b34d47..01c4e65 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -164,6 +164,7 @@ mod m20260521_000159_patient_phone_and_consent_seed; mod m20260521_000160_follow_up_task_template_id_and_record_form_data; mod m20260521_000161_consultation_media_id_and_suggestion_references; mod m20260521_000162_consultation_session_rating_feedback; +mod m20260521_000163_reorganize_menus_by_business_flow; pub struct Migrator; @@ -335,6 +336,7 @@ impl MigratorTrait for Migrator { Box::new(m20260521_000160_follow_up_task_template_id_and_record_form_data::Migration), Box::new(m20260521_000161_consultation_media_id_and_suggestion_references::Migration), Box::new(m20260521_000162_consultation_session_rating_feedback::Migration), + Box::new(m20260521_000163_reorganize_menus_by_business_flow::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260521_000163_reorganize_menus_by_business_flow.rs b/crates/erp-server/migration/src/m20260521_000163_reorganize_menus_by_business_flow.rs new file mode 100644 index 0000000..0cfe954 --- /dev/null +++ b/crates/erp-server/migration/src/m20260521_000163_reorganize_menus_by_business_flow.rs @@ -0,0 +1,418 @@ +//! 侧边栏菜单按业务流程重组 +//! +//! 将"健康业务"及其子目录(30+ 项)重组为 5 个顶级业务域目录: +//! 患者管理 / 诊疗服务 / 健康监测 / 运营管理 / AI 助手 +//! +//! 注意:menus 表有 trg_enforce_version 触发器,所有 UPDATE 必须带 version = version + 1 + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +const DIR_PATIENT_MGMT: &str = "a0000000-0000-0000-0000-000000000005"; +const DIR_CLINICAL: &str = "a0000000-0000-0000-0000-000000000006"; +const DIR_MONITORING: &str = "a0000000-0000-0000-0000-000000000007"; +const DIR_OPERATIONS: &str = "a0000000-0000-0000-0000-000000000008"; +const DIR_AI: &str = "a0000000-0000-0000-0000-000000000009"; +const SYS: &str = "00000000-0000-0000-0000-000000000000"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // ================================================================ + // Part 1: 创建 5 个新顶级 directory(多租户安全 + 幂等) + // ================================================================ + let directories: &[(&str, &str, i32)] = &[ + (DIR_PATIENT_MGMT, "患者管理", 2), + (DIR_CLINICAL, "诊疗服务", 3), + (DIR_MONITORING, "健康监测", 4), + (DIR_OPERATIONS, "运营管理", 5), + (DIR_AI, "AI 助手", 6), + ]; + + for &(id, title, sort) in directories { + db.execute_unprepared(&format!( + "INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, visible, menu_type, permission, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT '{id}', t.id, NULL, '{title}', NULL, NULL, {sort}, true, 'directory', NULL, NOW(), NOW(), '{SYS}', '{SYS}', NULL, 1 \ + FROM tenant t \ + ON CONFLICT (id) DO NOTHING" + )).await?; + } + + // 更新工作台 sort=1, 系统管理 sort=7 + db.execute_unprepared( + "UPDATE menus SET sort_order = 1, version = version + 1 WHERE title = '工作台' AND menu_type = 'directory' AND deleted_at IS NULL", + ).await?; + db.execute_unprepared( + "UPDATE menus SET sort_order = 7, version = version + 1 WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL", + ).await?; + + // ================================================================ + // Part 2: 移动叶子菜单到新目录(按 path 匹配,无视当前 parent_id) + // 所有 UPDATE 必须 version = version + 1(乐观锁触发器要求) + // ================================================================ + + // 患者管理 + for &(path, sort) in &[ + ("/health/patients", 0), + ("/health/tags", 1), + ("/health/doctors", 2), + ] { + db.execute_unprepared(&format!( + "UPDATE menus SET parent_id = '{DIR_PATIENT_MGMT}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL" + )).await?; + } + + // 诊疗服务 + for &(path, sort) in &[ + ("/health/schedules", 0), + ("/health/appointments", 1), + ("/health/follow-up-tasks", 2), + ("/health/follow-up-templates", 3), + ("/health/consultations", 4), + ("/health/action-inbox", 5), + ("/health/diagnoses", 6), + ("/health/consents", 7), + ("/health/daily-monitoring", 8), + ] { + db.execute_unprepared(&format!( + "UPDATE menus SET parent_id = '{DIR_CLINICAL}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL" + )).await?; + } + + // 健康监测 + for &(path, sort) in &[ + ("/health/realtime-monitor", 0), + ("/health/alert-dashboard", 1), + ("/health/alerts", 2), + ("/health/alert-rules", 3), + ("/health/devices", 4), + ("/health/ble-gateways", 5), + ("/health/critical-value-thresholds", 6), + ] { + db.execute_unprepared(&format!( + "UPDATE menus SET parent_id = '{DIR_MONITORING}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL" + )).await?; + } + + // 运营管理 + for &(path, sort) in &[ + ("/health/articles", 0), + ("/health/article-categories", 1), + ("/health/article-tags", 2), + ("/health/points-rules", 3), + ("/health/points-products", 4), + ("/health/points-orders", 5), + ("/health/offline-events", 6), + ("/health/media-library", 7), + ("/health/banners", 8), + ] { + db.execute_unprepared(&format!( + "UPDATE menus SET parent_id = '{DIR_OPERATIONS}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL" + )).await?; + } + + // AI 助手 + for &(path, sort) in &[ + ("/ai/chat", 0), + ("/health/ai-prompts", 1), + ("/health/ai-analysis", 2), + ("/health/ai-knowledge", 3), + ("/health/ai-usage", 4), + ("/health/ai-config", 5), + ] { + db.execute_unprepared(&format!( + "UPDATE menus SET parent_id = '{DIR_AI}', sort_order = {sort}, version = version + 1 WHERE path = '{path}' AND deleted_at IS NULL" + )).await?; + } + + // ================================================================ + // Part 3: 移动 OAuth 到系统管理 + // ================================================================ + db.execute_unprepared( + "UPDATE menus SET parent_id = (SELECT id FROM menus WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1), sort_order = 7, version = version + 1 WHERE path = '/health/oauth-clients' AND deleted_at IS NULL" + ).await?; + + // ================================================================ + // Part 4: 清理旧目录 + // ================================================================ + + // 软删除"健康业务"目录及其子目录 + db.execute_unprepared( + "UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE title = '健康业务' AND menu_type = 'directory' AND deleted_at IS NULL" + ).await?; + + // 软删除"健康业务"下的旧子目录(c0000003-xxx) + db.execute_unprepared( + "UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE parent_id = (SELECT id FROM menus WHERE title = '健康业务' AND menu_type = 'directory' LIMIT 1) AND menu_type = 'directory' AND deleted_at IS NULL" + ).await?; + + // 通过 title 匹配也清理(防止 parent_id 引用失效后遗漏) + for old_dir in &["患者医护", "随访咨询", "积分运营", "内容运营", "AI 分析"] + { + db.execute_unprepared(&format!( + "UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE title = '{old_dir}' AND menu_type = 'directory' AND deleted_at IS NULL" + )).await?; + } + + // 软删除"配置"目录(如果还存在且已空) + db.execute_unprepared( + "UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE title = '配置' AND menu_type = 'directory' AND deleted_at IS NULL AND NOT EXISTS (SELECT 1 FROM menus c WHERE c.parent_id = menus.id AND c.deleted_at IS NULL AND c.visible = true)" + ).await?; + + // ================================================================ + // Part 5: 重建 menu_roles + // ================================================================ + for code in &["doctor", "nurse", "health_manager", "operator", "admin"] { + db.execute_unprepared(&format!( + "UPDATE menu_roles SET deleted_at = NOW(), version = version + 1 WHERE role_id IN (SELECT id FROM roles WHERE code = '{code}' AND deleted_at IS NULL) AND deleted_at IS NULL" + )).await?; + } + + assign_menus_for_role( + db, + "doctor", + &[ + "/", + "/health/statistics", + "/health/patients", + "/health/doctors", + "/health/follow-up-tasks", + "/health/follow-up-templates", + "/health/consultations", + "/health/action-inbox", + "/health/diagnoses", + "/health/consents", + "/health/daily-monitoring", + "/health/alert-dashboard", + "/health/alerts", + "/health/ai-analysis", + "/health/ai-usage", + "/ai/chat", + "/messages", + ], + ) + .await?; + + assign_menus_for_role( + db, + "nurse", + &[ + "/", + "/health/statistics", + "/health/patients", + "/health/follow-up-tasks", + "/health/consultations", + "/health/action-inbox", + "/health/diagnoses", + "/health/consents", + "/health/daily-monitoring", + "/health/alert-dashboard", + "/health/alerts", + "/messages", + ], + ) + .await?; + + assign_menus_for_role( + db, + "health_manager", + &[ + "/", + "/health/statistics", + "/health/patients", + "/health/doctors", + "/health/tags", + "/health/follow-up-tasks", + "/health/follow-up-templates", + "/health/consultations", + "/health/action-inbox", + "/health/diagnoses", + "/health/consents", + "/health/daily-monitoring", + "/health/realtime-monitor", + "/health/alert-dashboard", + "/health/alerts", + "/health/alert-rules", + "/health/devices", + "/health/critical-value-thresholds", + "/health/ai-prompts", + "/health/ai-analysis", + "/health/ai-knowledge", + "/health/ai-usage", + "/health/ai-config", + "/ai/chat", + "/messages", + ], + ) + .await?; + + assign_menus_for_role( + db, + "operator", + &[ + "/", + "/health/statistics", + "/health/patients", + "/health/tags", + "/health/devices", + "/health/alert-dashboard", + "/health/alerts", + "/health/articles", + "/health/article-categories", + "/health/article-tags", + "/health/points-rules", + "/health/points-products", + "/health/points-orders", + "/health/offline-events", + "/health/media-library", + "/health/banners", + "/health/ai-usage", + "/messages", + ], + ) + .await?; + + assign_admin_all_menus(db).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 恢复"健康业务"目录 + db.execute_unprepared( + "UPDATE menus SET deleted_at = NULL, version = version + 1 WHERE title = '健康业务' AND menu_type = 'directory'" + ).await?; + + // 将所有健康相关叶子菜单移回"健康业务" + let health_dir = "(SELECT id FROM menus WHERE title = '健康业务' AND menu_type = 'directory' AND deleted_at IS NULL LIMIT 1)"; + let paths_to_move: &[&str] = &[ + "/health/patients", + "/health/doctors", + "/health/tags", + "/health/diagnoses", + "/health/schedules", + "/health/appointments", + "/health/follow-up-tasks", + "/health/follow-up-templates", + "/health/consultations", + "/health/action-inbox", + "/health/daily-monitoring", + "/health/consents", + "/health/realtime-monitor", + "/health/alert-dashboard", + "/health/alerts", + "/health/alert-rules", + "/health/devices", + "/health/ble-gateways", + "/health/critical-value-thresholds", + "/health/articles", + "/health/article-categories", + "/health/article-tags", + "/health/points-rules", + "/health/points-products", + "/health/points-orders", + "/health/offline-events", + "/health/media-library", + "/health/banners", + "/health/ai-prompts", + "/health/ai-analysis", + "/health/ai-knowledge", + "/health/ai-usage", + "/health/ai-config", + "/ai/chat", + ]; + let paths_csv: String = paths_to_move + .iter() + .map(|p| format!("'{p}'")) + .collect::>() + .join(","); + db.execute_unprepared(&format!( + "UPDATE menus SET parent_id = {health_dir}, version = version + 1 WHERE path IN ({paths_csv}) AND deleted_at IS NULL" + )).await?; + + // OAuth 移回健康业务 + db.execute_unprepared(&format!( + "UPDATE menus SET parent_id = {health_dir}, sort_order = 60, version = version + 1 WHERE path = '/health/oauth-clients' AND deleted_at IS NULL" + )).await?; + + // 删除新建的 5 个目录 + for id in &[ + DIR_PATIENT_MGMT, + DIR_CLINICAL, + DIR_MONITORING, + DIR_OPERATIONS, + DIR_AI, + ] { + db.execute_unprepared(&format!( + "UPDATE menus SET deleted_at = NOW(), version = version + 1 WHERE id = '{id}'" + )) + .await?; + } + + // 恢复目录排序 + db.execute_unprepared( + "UPDATE menus SET sort_order = 1, version = version + 1 WHERE title = '工作台' AND menu_type = 'directory' AND deleted_at IS NULL", + ).await?; + db.execute_unprepared( + "UPDATE menus SET sort_order = 2, version = version + 1 WHERE title = '系统管理' AND menu_type = 'directory' AND deleted_at IS NULL", + ).await?; + db.execute_unprepared( + "UPDATE menus SET sort_order = 3, version = version + 1 WHERE title = '健康业务' AND menu_type = 'directory' AND deleted_at IS NULL", + ).await?; + + Ok(()) + } +} + +async fn assign_menus_for_role( + db: &sea_orm_migration::SchemaManagerConnection<'_>, + role_code: &str, + menu_paths: &[&str], +) -> Result<(), DbErr> { + let paths_csv: String = menu_paths + .iter() + .map(|p| format!("'{p}'")) + .collect::>() + .join(","); + + db.execute_unprepared(&format!( + "INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN menus m ON m.tenant_id = r.tenant_id AND m.path IN ({paths_csv}) AND m.deleted_at IS NULL AND m.visible = true \ + WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \ + ON CONFLICT (id) DO NOTHING" + )).await?; + + db.execute_unprepared(&format!( + "INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN menus m ON m.tenant_id = r.tenant_id AND m.menu_type = 'directory' AND m.deleted_at IS NULL \ + WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \ + ON CONFLICT (id) DO NOTHING" + )).await?; + + Ok(()) +} + +async fn assign_admin_all_menus( + db: &sea_orm_migration::SchemaManagerConnection<'_>, +) -> Result<(), DbErr> { + db.execute_unprepared( + "INSERT INTO menu_roles (id, menu_id, role_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT gen_random_uuid(), m.id, r.id, r.tenant_id, NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN menus m ON m.tenant_id = r.tenant_id AND m.deleted_at IS NULL AND m.visible = true \ + WHERE r.code = 'admin' AND r.deleted_at IS NULL \ + ON CONFLICT (id) DO NOTHING" + ).await?; + + Ok(()) +}