diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 5cd9050..744e9e4 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -115,6 +115,7 @@ mod m20260505_000112_create_shift_management; mod m20260505_000113_create_ble_gateways; mod m20260505_000114_dialysis_record_add_workflow_instance; mod m20260505_000115_family_member_health_proxy; +mod m20260505_000116_seed_missing_health_menus; pub struct Migrator; @@ -237,6 +238,7 @@ impl MigratorTrait for Migrator { Box::new(m20260505_000113_create_ble_gateways::Migration), Box::new(m20260505_000114_dialysis_record_add_workflow_instance::Migration), Box::new(m20260505_000115_family_member_health_proxy::Migration), + Box::new(m20260505_000116_seed_missing_health_menus::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260502_000101_seed_health_dictionaries.rs b/crates/erp-server/migration/src/m20260502_000101_seed_health_dictionaries.rs index 3e29568..bb1ada4 100644 --- a/crates/erp-server/migration/src/m20260502_000101_seed_health_dictionaries.rs +++ b/crates/erp-server/migration/src/m20260502_000101_seed_health_dictionaries.rs @@ -195,7 +195,7 @@ async fn insert_item( ) -> Result<(), DbErr> { // 生成条目 ID:基于字典 ID 后缀 + 序号 let suffix = &dict_id[24..]; - let item_id = format!("d200{idx:04x}-0000-0000-{suffix}"); + let item_id = format!("d200{idx:04x}-0000-0000-0000-{suffix}"); let color_sql = match color { Some(c) => format!("'{c}'"), None => "NULL".to_string(), diff --git a/crates/erp-server/migration/src/m20260504_000104_create_vital_signs_daily.rs b/crates/erp-server/migration/src/m20260504_000104_create_vital_signs_daily.rs index 7ed4d26..5df65fa 100644 --- a/crates/erp-server/migration/src/m20260504_000104_create_vital_signs_daily.rs +++ b/crates/erp-server/migration/src/m20260504_000104_create_vital_signs_daily.rs @@ -14,7 +14,7 @@ impl MigrationTrait for Migration { ColumnDef::new(Alias::new("id")) .uuid() .not_null() - .default(Expr::val("gen_random_uuid()")), + .default(Expr::cust("gen_random_uuid()")), ) .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) .col(ColumnDef::new(Alias::new("patient_id")).uuid().not_null()) @@ -29,13 +29,13 @@ impl MigrationTrait for Migration { ColumnDef::new(Alias::new("created_at")) .timestamp_with_time_zone() .not_null() - .default(Expr::val("NOW()")), + .default(Expr::cust("NOW()")), ) .col( ColumnDef::new(Alias::new("updated_at")) .timestamp_with_time_zone() .not_null() - .default(Expr::val("NOW()")), + .default(Expr::cust("NOW()")), ) .col( ColumnDef::new(Alias::new("version")) diff --git a/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs b/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs index 363c90f..9770d33 100644 --- a/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs +++ b/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs @@ -14,7 +14,7 @@ impl MigrationTrait for Migration { ColumnDef::new(Alias::new("id")) .uuid() .not_null() - .default(Expr::val("gen_random_uuid()")), + .default(Expr::cust("gen_random_uuid()")), ) .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) .col( @@ -56,13 +56,13 @@ impl MigrationTrait for Migration { ColumnDef::new(Alias::new("created_at")) .timestamp_with_time_zone() .not_null() - .default(Expr::val("NOW()")), + .default(Expr::cust("NOW()")), ) .col( ColumnDef::new(Alias::new("updated_at")) .timestamp_with_time_zone() .not_null() - .default(Expr::val("NOW()")), + .default(Expr::cust("NOW()")), ) .col(ColumnDef::new(Alias::new("created_by")).uuid().null()) .col(ColumnDef::new(Alias::new("updated_by")).uuid().null()) diff --git a/crates/erp-server/migration/src/m20260504_000107_alter_article_article_tag_add_tenant_and_soft_delete.rs b/crates/erp-server/migration/src/m20260504_000107_alter_article_article_tag_add_tenant_and_soft_delete.rs index 5ac59f1..1bbf5f7 100644 --- a/crates/erp-server/migration/src/m20260504_000107_alter_article_article_tag_add_tenant_and_soft_delete.rs +++ b/crates/erp-server/migration/src/m20260504_000107_alter_article_article_tag_add_tenant_and_soft_delete.rs @@ -39,7 +39,7 @@ impl MigrationTrait for Migration { ColumnDef::new(Alias::new("created_at")) .timestamp_with_time_zone() .not_null() - .default(Expr::val("NOW()")), + .default(Expr::cust("NOW()")), ) .to_owned(), ) @@ -53,7 +53,7 @@ impl MigrationTrait for Migration { ColumnDef::new(Alias::new("updated_at")) .timestamp_with_time_zone() .not_null() - .default(Expr::val("NOW()")), + .default(Expr::cust("NOW()")), ) .to_owned(), ) diff --git a/crates/erp-server/migration/src/m20260504_000109_add_missing_fk_constraints.rs b/crates/erp-server/migration/src/m20260504_000109_add_missing_fk_constraints.rs index a44be77..acb66f4 100644 --- a/crates/erp-server/migration/src/m20260504_000109_add_missing_fk_constraints.rs +++ b/crates/erp-server/migration/src/m20260504_000109_add_missing_fk_constraints.rs @@ -6,104 +6,43 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - // follow_up_task.related_appointment_id → appointment.id - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_follow_up_task_appointment") - .from(Alias::new("follow_up_task"), Alias::new("related_appointment_id")) - .to(Alias::new("appointment"), Alias::new("id")) - .on_delete(ForeignKeyAction::SetNull) - .to_owned(), - ) - .await?; + let db = manager.get_connection(); - // points_transaction.account_id → points_account.id - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_points_transaction_account") - .from(Alias::new("points_transaction"), Alias::new("account_id")) - .to(Alias::new("points_account"), Alias::new("id")) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await?; + // 安全创建外键:先检查是否已存在,不存在才创建 + let fks: &[(&str, &str, &str, &str, &str, &str)] = &[ + ("fk_follow_up_task_appointment", "follow_up_task", "related_appointment_id", "appointment", "id", "SET NULL"), + ("fk_points_transaction_account", "points_transaction", "account_id", "points_account", "id", "CASCADE"), + ("fk_points_transaction_rule", "points_transaction", "rule_id", "points_rule", "id", "SET NULL"), + ("fk_points_transaction_order", "points_transaction", "order_id", "points_order", "id", "SET NULL"), + ("fk_points_order_product", "points_order", "product_id", "points_product", "id", "RESTRICT"), + ("fk_points_order_patient", "points_order", "patient_id", "patient", "id", "CASCADE"), + ("fk_offline_event_registration_event", "offline_event_registration", "event_id", "offline_event", "id", "CASCADE"), + ("fk_offline_event_registration_patient", "offline_event_registration", "patient_id", "patient", "id", "CASCADE"), + ]; - // points_transaction.rule_id → points_rule.id - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_points_transaction_rule") - .from(Alias::new("points_transaction"), Alias::new("rule_id")) - .to(Alias::new("points_rule"), Alias::new("id")) - .on_delete(ForeignKeyAction::SetNull) - .to_owned(), - ) - .await?; + for &(name, from_table, from_col, to_table, to_col, on_delete) in fks { + let sql = format!( + r#"DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = '{name}' AND table_name = '{from_table}' + ) THEN + ALTER TABLE {from_table} + ADD CONSTRAINT {name} + FOREIGN KEY ({from_col}) REFERENCES {to_table}({to_col}) + ON DELETE {on_delete}; + END IF; + END$$;"# + ); + db.execute_unprepared(&sql).await?; + } - // points_transaction.order_id → points_order.id - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_points_transaction_order") - .from(Alias::new("points_transaction"), Alias::new("order_id")) - .to(Alias::new("points_order"), Alias::new("id")) - .on_delete(ForeignKeyAction::SetNull) - .to_owned(), - ) - .await?; - - // points_order.product_id → points_product.id - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_points_order_product") - .from(Alias::new("points_order"), Alias::new("product_id")) - .to(Alias::new("points_product"), Alias::new("id")) - .on_delete(ForeignKeyAction::Restrict) - .to_owned(), - ) - .await?; - - // points_order.patient_id → patient.id - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_points_order_patient") - .from(Alias::new("points_order"), Alias::new("patient_id")) - .to(Alias::new("patient"), Alias::new("id")) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await?; - - // offline_event_registration.event_id → offline_event.id - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_offline_event_registration_event") - .from(Alias::new("offline_event_registration"), Alias::new("event_id")) - .to(Alias::new("offline_event"), Alias::new("id")) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await?; - - // offline_event_registration.patient_id → patient.id - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_offline_event_registration_patient") - .from(Alias::new("offline_event_registration"), Alias::new("patient_id")) - .to(Alias::new("patient"), Alias::new("id")) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await + Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); let fks = [ "fk_offline_event_registration_patient", "fk_offline_event_registration_event", @@ -114,15 +53,12 @@ impl MigrationTrait for Migration { "fk_points_transaction_account", "fk_follow_up_task_appointment", ]; - for fk in fks { - manager - .drop_foreign_key( - ForeignKey::drop() - .name(fk) - .table(Alias::new("dummy")) - .to_owned(), - ) - .await?; + for fk in &fks { + db.execute_unprepared(&format!( + "ALTER TABLE dummy DROP CONSTRAINT IF EXISTS {fk}" + )) + .await + .ok(); } Ok(()) } diff --git a/crates/erp-server/migration/src/m20260504_000110_alter_critical_alerts_version_i32.rs b/crates/erp-server/migration/src/m20260504_000110_alter_critical_alerts_version_i32.rs index 03e6534..f411975 100644 --- a/crates/erp-server/migration/src/m20260504_000110_alter_critical_alerts_version_i32.rs +++ b/crates/erp-server/migration/src/m20260504_000110_alter_critical_alerts_version_i32.rs @@ -6,11 +6,11 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - // critical_alerts.version: i64 → i32 + // critical_alert.version: i64 → i32 manager .alter_table( Table::alter() - .table(Alias::new("critical_alerts")) + .table(Alias::new("critical_alert")) .modify_column( ColumnDef::new(Alias::new("version")) .integer() @@ -21,11 +21,11 @@ impl MigrationTrait for Migration { ) .await?; - // critical_alert_responses.version: i64 → i32 + // critical_alert_response.version: i64 → i32 manager .alter_table( Table::alter() - .table(Alias::new("critical_alert_responses")) + .table(Alias::new("critical_alert_response")) .modify_column( ColumnDef::new(Alias::new("version")) .integer() @@ -41,7 +41,7 @@ impl MigrationTrait for Migration { manager .alter_table( Table::alter() - .table(Alias::new("critical_alerts")) + .table(Alias::new("critical_alert")) .modify_column( ColumnDef::new(Alias::new("version")) .big_integer() @@ -55,7 +55,7 @@ impl MigrationTrait for Migration { manager .alter_table( Table::alter() - .table(Alias::new("critical_alert_responses")) + .table(Alias::new("critical_alert_response")) .modify_column( ColumnDef::new(Alias::new("version")) .big_integer() diff --git a/crates/erp-server/migration/src/m20260505_000111_create_care_plan.rs b/crates/erp-server/migration/src/m20260505_000111_create_care_plan.rs index ee89b1e..05917ef 100644 --- a/crates/erp-server/migration/src/m20260505_000111_create_care_plan.rs +++ b/crates/erp-server/migration/src/m20260505_000111_create_care_plan.rs @@ -11,6 +11,7 @@ impl MigrationTrait for Migration { .create_table( Table::create() .table(Alias::new("care_plans")) + .if_not_exists() .col( ColumnDef::new(Alias::new("id")) .uuid() @@ -35,7 +36,7 @@ impl MigrationTrait for Migration { ColumnDef::new(Alias::new("goals")) .json_binary() .not_null() - .default("[]"), + .default(Expr::cust("'[]'::jsonb")), ) .col(ColumnDef::new(Alias::new("start_date")).date()) .col(ColumnDef::new(Alias::new("end_date")).date()) @@ -66,6 +67,7 @@ impl MigrationTrait for Migration { manager .create_index( Index::create() + .if_not_exists() .name("idx_care_plans_tenant_patient") .table(Alias::new("care_plans")) .col(Alias::new("tenant_id")) @@ -78,6 +80,7 @@ impl MigrationTrait for Migration { manager .create_index( Index::create() + .if_not_exists() .name("idx_care_plans_tenant_status") .table(Alias::new("care_plans")) .col(Alias::new("tenant_id")) @@ -92,6 +95,7 @@ impl MigrationTrait for Migration { .create_table( Table::create() .table(Alias::new("care_plan_items")) + .if_not_exists() .col( ColumnDef::new(Alias::new("id")) .uuid() @@ -153,6 +157,7 @@ impl MigrationTrait for Migration { manager .create_index( Index::create() + .if_not_exists() .name("idx_care_plan_items_tenant_plan") .table(Alias::new("care_plan_items")) .col(Alias::new("tenant_id")) @@ -167,6 +172,7 @@ impl MigrationTrait for Migration { .create_table( Table::create() .table(Alias::new("care_plan_outcomes")) + .if_not_exists() .col( ColumnDef::new(Alias::new("id")) .uuid() @@ -231,6 +237,7 @@ impl MigrationTrait for Migration { manager .create_index( Index::create() + .if_not_exists() .name("idx_care_plan_outcomes_tenant_plan") .table(Alias::new("care_plan_outcomes")) .col(Alias::new("tenant_id")) diff --git a/crates/erp-server/migration/src/m20260505_000112_create_shift_management.rs b/crates/erp-server/migration/src/m20260505_000112_create_shift_management.rs index 707a631..3613fc4 100644 --- a/crates/erp-server/migration/src/m20260505_000112_create_shift_management.rs +++ b/crates/erp-server/migration/src/m20260505_000112_create_shift_management.rs @@ -6,245 +6,100 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - // 班次表 - manager - .create_table( - Table::create() - .table(Shift::Table) - .col( - ColumnDef::new(Shift::Id) - .uuid() - .not_null() - .primary_key(), - ) - .col(ColumnDef::new(Shift::TenantId).uuid().not_null()) - .col(ColumnDef::new(Shift::ShiftDate).date().not_null()) - .col(ColumnDef::new(Shift::Period).string_len(20).not_null()) - .col(ColumnDef::new(Shift::NurseId).uuid()) - .col(ColumnDef::new(Shift::Status).string_len(20).not_null().default("scheduled")) - .col(ColumnDef::new(Shift::Notes).text()) - .col(ColumnDef::new(Shift::CreatedAt).timestamp_with_time_zone().not_null()) - .col(ColumnDef::new(Shift::UpdatedAt).timestamp_with_time_zone().not_null()) - .col(ColumnDef::new(Shift::CreatedBy).uuid()) - .col(ColumnDef::new(Shift::UpdatedBy).uuid()) - .col(ColumnDef::new(Shift::DeletedAt).timestamp_with_time_zone()) - .col(ColumnDef::new(Shift::Version).integer().not_null().default(1)) - .to_owned(), - ) - .await?; + let db = manager.get_connection(); - manager - .create_index( - Index::create() - .name("idx_shifts_tenant_date") - .table(Shift::Table) - .col(Shift::TenantId) - .col(Shift::ShiftDate) - .col(Shift::DeletedAt) - .to_owned(), - ) - .await?; + db.execute_unprepared( + r#" + CREATE TABLE IF NOT EXISTS shift ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + shift_date DATE NOT NULL, + period VARCHAR(20) NOT NULL, + nurse_id UUID, + status VARCHAR(20) NOT NULL DEFAULT 'scheduled', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + created_by UUID, + updated_by UUID, + deleted_at TIMESTAMPTZ, + version INTEGER NOT NULL DEFAULT 1 + ); - manager - .create_index( - Index::create() - .name("idx_shifts_tenant_nurse") - .table(Shift::Table) - .col(Shift::TenantId) - .col(Shift::NurseId) - .col(Shift::DeletedAt) - .to_owned(), - ) - .await?; + CREATE TABLE IF NOT EXISTS patient_assignment ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + shift_id UUID NOT NULL, + patient_id UUID NOT NULL, + care_level VARCHAR(20) NOT NULL DEFAULT 'routine', + notes TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + created_by UUID, + updated_by UUID, + deleted_at TIMESTAMPTZ, + version INTEGER NOT NULL DEFAULT 1 + ); - // 患者分配表 - manager - .create_table( - Table::create() - .table(PatientAssignment::Table) - .col( - ColumnDef::new(PatientAssignment::Id) - .uuid() - .not_null() - .primary_key(), - ) - .col(ColumnDef::new(PatientAssignment::TenantId).uuid().not_null()) - .col(ColumnDef::new(PatientAssignment::ShiftId).uuid().not_null()) - .col(ColumnDef::new(PatientAssignment::PatientId).uuid().not_null()) - .col(ColumnDef::new(PatientAssignment::CareLevel).string_len(20).not_null().default("routine")) - .col(ColumnDef::new(PatientAssignment::Notes).text()) - .col(ColumnDef::new(PatientAssignment::CreatedAt).timestamp_with_time_zone().not_null()) - .col(ColumnDef::new(PatientAssignment::UpdatedAt).timestamp_with_time_zone().not_null()) - .col(ColumnDef::new(PatientAssignment::CreatedBy).uuid()) - .col(ColumnDef::new(PatientAssignment::UpdatedBy).uuid()) - .col(ColumnDef::new(PatientAssignment::DeletedAt).timestamp_with_time_zone()) - .col(ColumnDef::new(PatientAssignment::Version).integer().not_null().default(1)) - .to_owned(), - ) - .await?; + CREATE TABLE IF NOT EXISTS handoff_log ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + from_shift_id UUID NOT NULL, + to_shift_id UUID NOT NULL, + patient_id UUID NOT NULL, + notes TEXT, + pending_items JSONB, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + created_by UUID, + updated_by UUID, + deleted_at TIMESTAMPTZ, + version INTEGER NOT NULL DEFAULT 1 + ); + "#, + ) + .await?; - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_patient_assignments_shift") - .from(PatientAssignment::Table, PatientAssignment::ShiftId) - .to(Shift::Table, Shift::Id) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await?; + // 索引(幂等) + let indexes = [ + "CREATE INDEX IF NOT EXISTS idx_shifts_tenant_date ON shift (tenant_id, shift_date, deleted_at)", + "CREATE INDEX IF NOT EXISTS idx_shifts_tenant_nurse ON shift (tenant_id, nurse_id, deleted_at)", + "CREATE INDEX IF NOT EXISTS idx_patient_assignments_shift ON patient_assignment (tenant_id, shift_id, deleted_at)", + "CREATE INDEX IF NOT EXISTS idx_patient_assignments_patient ON patient_assignment (tenant_id, patient_id, deleted_at)", + "CREATE INDEX IF NOT EXISTS idx_handoff_log_to_shift ON handoff_log (tenant_id, to_shift_id, deleted_at)", + ]; + for sql in &indexes { + db.execute_unprepared(sql).await.ok(); + } - manager - .create_index( - Index::create() - .name("idx_patient_assignments_shift") - .table(PatientAssignment::Table) - .col(PatientAssignment::TenantId) - .col(PatientAssignment::ShiftId) - .col(PatientAssignment::DeletedAt) - .to_owned(), - ) - .await?; - - manager - .create_index( - Index::create() - .name("idx_patient_assignments_patient") - .table(PatientAssignment::Table) - .col(PatientAssignment::TenantId) - .col(PatientAssignment::PatientId) - .col(PatientAssignment::DeletedAt) - .to_owned(), - ) - .await?; - - // 交接日志表 - manager - .create_table( - Table::create() - .table(HandoffLog::Table) - .col( - ColumnDef::new(HandoffLog::Id) - .uuid() - .not_null() - .primary_key(), - ) - .col(ColumnDef::new(HandoffLog::TenantId).uuid().not_null()) - .col(ColumnDef::new(HandoffLog::FromShiftId).uuid().not_null()) - .col(ColumnDef::new(HandoffLog::ToShiftId).uuid().not_null()) - .col(ColumnDef::new(HandoffLog::PatientId).uuid().not_null()) - .col(ColumnDef::new(HandoffLog::Notes).text()) - .col(ColumnDef::new(HandoffLog::PendingItems).json_binary()) - .col(ColumnDef::new(HandoffLog::CreatedAt).timestamp_with_time_zone().not_null()) - .col(ColumnDef::new(HandoffLog::UpdatedAt).timestamp_with_time_zone().not_null()) - .col(ColumnDef::new(HandoffLog::CreatedBy).uuid()) - .col(ColumnDef::new(HandoffLog::UpdatedBy).uuid()) - .col(ColumnDef::new(HandoffLog::DeletedAt).timestamp_with_time_zone()) - .col(ColumnDef::new(HandoffLog::Version).integer().not_null().default(1)) - .to_owned(), - ) - .await?; - - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_handoff_log_from_shift") - .from(HandoffLog::Table, HandoffLog::FromShiftId) - .to(Shift::Table, Shift::Id) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await?; - - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_handoff_log_to_shift") - .from(HandoffLog::Table, HandoffLog::ToShiftId) - .to(Shift::Table, Shift::Id) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await?; - - manager - .create_index( - Index::create() - .name("idx_handoff_log_to_shift") - .table(HandoffLog::Table) - .col(HandoffLog::TenantId) - .col(HandoffLog::ToShiftId) - .col(HandoffLog::DeletedAt) - .to_owned(), - ) - .await?; + // 外键(幂等) + let fks = [ + ("fk_patient_assignments_shift", "ALTER TABLE patient_assignment ADD CONSTRAINT fk_patient_assignments_shift FOREIGN KEY (shift_id) REFERENCES shift(id) ON DELETE CASCADE"), + ("fk_handoff_log_from_shift", "ALTER TABLE handoff_log ADD CONSTRAINT fk_handoff_log_from_shift FOREIGN KEY (from_shift_id) REFERENCES shift(id) ON DELETE CASCADE"), + ("fk_handoff_log_to_shift", "ALTER TABLE handoff_log ADD CONSTRAINT fk_handoff_log_to_shift FOREIGN KEY (to_shift_id) REFERENCES shift(id) ON DELETE CASCADE"), + ]; + for (name, sql) in &fks { + let check = format!( + "SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_name = '{name}'" + ); + if let Some(row) = db.query_one(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, check, + )).await? { + let count: i64 = row.try_get_by_index::(0).unwrap_or(0); + if count == 0 { + db.execute_unprepared(sql).await?; + } + } + } Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(HandoffLog::Table).to_owned()) - .await?; - manager - .drop_table(Table::drop().table(PatientAssignment::Table).to_owned()) - .await?; - manager - .drop_table(Table::drop().table(Shift::Table).to_owned()) - .await?; + let db = manager.get_connection(); + db.execute_unprepared("DROP TABLE IF EXISTS handoff_log").await?; + db.execute_unprepared("DROP TABLE IF EXISTS patient_assignment").await?; + db.execute_unprepared("DROP TABLE IF EXISTS shift").await?; Ok(()) } } - -#[derive(DeriveIden)] -enum Shift { - Table, - Id, - TenantId, - ShiftDate, - Period, - NurseId, - Status, - Notes, - CreatedAt, - UpdatedAt, - CreatedBy, - UpdatedBy, - DeletedAt, - Version, -} - -#[derive(DeriveIden)] -enum PatientAssignment { - Table, - Id, - TenantId, - ShiftId, - PatientId, - CareLevel, - Notes, - CreatedAt, - UpdatedAt, - CreatedBy, - UpdatedBy, - DeletedAt, - Version, -} - -#[derive(DeriveIden)] -enum HandoffLog { - Table, - Id, - TenantId, - FromShiftId, - ToShiftId, - PatientId, - Notes, - PendingItems, - CreatedAt, - UpdatedAt, - CreatedBy, - UpdatedBy, - DeletedAt, - Version, -} diff --git a/crates/erp-server/migration/src/m20260505_000113_create_ble_gateways.rs b/crates/erp-server/migration/src/m20260505_000113_create_ble_gateways.rs index 49d4a26..5c32dc5 100644 --- a/crates/erp-server/migration/src/m20260505_000113_create_ble_gateways.rs +++ b/crates/erp-server/migration/src/m20260505_000113_create_ble_gateways.rs @@ -1,4 +1,4 @@ -use sea_orm_migration::{prelude::*, schema::*}; +use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; @@ -6,125 +6,87 @@ pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .create_table( - Table::create() - .table(Alias::new("ble_gateways")) - .col(uuid("id").primary_key()) - .col(uuid("tenant_id").not_null()) - .col(string("gateway_id").unique_key().not_null()) - .col(string("name").not_null()) - .col(string("api_key_hash").not_null()) - .col(string("api_key_prefix").not_null()) - .col(string("status").default("active").not_null()) - .col(string("firmware_version").null()) - .col(string("ip_address").null()) - .col(timestamp_with_time_zone("last_heartbeat_at").null()) - .col(json("metadata").null()) - .col(timestamp_with_time_zone("created_at").default(Expr::current_timestamp()).not_null()) - .col(timestamp_with_time_zone("updated_at").default(Expr::current_timestamp()).not_null()) - .col(uuid("created_by").null()) - .col(uuid("updated_by").null()) - .col(timestamp_with_time_zone("deleted_at").null()) - .col(integer("version").default(1).not_null()) - .to_owned(), - ) - .await?; + let db = manager.get_connection(); - manager - .create_table( - Table::create() - .table(Alias::new("gateway_patient_bindings")) - .col(uuid("id").primary_key()) - .col(uuid("tenant_id").not_null()) - .col(uuid("gateway_id_fk").not_null()) - .col(uuid("patient_id").not_null()) - .col(string("peripheral_mac").null()) - .col(string("device_type").null()) - .col(string("status").default("active").not_null()) - .col(timestamp_with_time_zone("created_at").default(Expr::current_timestamp()).not_null()) - .col(timestamp_with_time_zone("updated_at").default(Expr::current_timestamp()).not_null()) - .col(uuid("created_by").null()) - .col(uuid("updated_by").null()) - .col(timestamp_with_time_zone("deleted_at").null()) - .col(integer("version").default(1).not_null()) - .to_owned(), - ) - .await?; + db.execute_unprepared( + r#" + CREATE TABLE IF NOT EXISTS ble_gateways ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + gateway_id VARCHAR NOT NULL UNIQUE, + name VARCHAR NOT NULL, + api_key_hash VARCHAR NOT NULL, + api_key_prefix VARCHAR NOT NULL, + status VARCHAR NOT NULL DEFAULT 'active', + firmware_version VARCHAR, + ip_address VARCHAR, + last_heartbeat_at TIMESTAMPTZ, + metadata JSON, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID, + updated_by UUID, + deleted_at TIMESTAMPTZ, + version INTEGER NOT NULL DEFAULT 1 + ); - // 索引 - manager - .create_index( - Index::create() - .name("idx_ble_gateways_tenant_id") - .table(Alias::new("ble_gateways")) - .col(Alias::new("tenant_id")) - .to_owned(), - ) - .await?; + CREATE TABLE IF NOT EXISTS gateway_patient_bindings ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + gateway_id_fk UUID NOT NULL, + patient_id UUID NOT NULL, + peripheral_mac VARCHAR, + device_type VARCHAR, + status VARCHAR NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID, + updated_by UUID, + deleted_at TIMESTAMPTZ, + version INTEGER NOT NULL DEFAULT 1 + ); + "#, + ) + .await?; - manager - .create_index( - Index::create() - .name("idx_ble_gateways_api_key_prefix") - .table(Alias::new("ble_gateways")) - .col(Alias::new("api_key_prefix")) - .to_owned(), - ) - .await?; + // 索引(幂等) + let indexes = [ + ("idx_ble_gateways_tenant_id", "CREATE INDEX IF NOT EXISTS idx_ble_gateways_tenant_id ON ble_gateways (tenant_id)"), + ("idx_ble_gateways_api_key_prefix", "CREATE INDEX IF NOT EXISTS idx_ble_gateways_api_key_prefix ON ble_gateways (api_key_prefix)"), + ("idx_gateway_patient_bindings_gateway", "CREATE INDEX IF NOT EXISTS idx_gateway_patient_bindings_gateway ON gateway_patient_bindings (gateway_id_fk)"), + ("idx_gateway_patient_bindings_patient", "CREATE INDEX IF NOT EXISTS idx_gateway_patient_bindings_patient ON gateway_patient_bindings (patient_id)"), + ]; + for (_, sql) in &indexes { + db.execute_unprepared(sql).await.ok(); + } - manager - .create_index( - Index::create() - .name("idx_gateway_patient_bindings_gateway") - .table(Alias::new("gateway_patient_bindings")) - .col(Alias::new("gateway_id_fk")) - .to_owned(), - ) - .await?; - - manager - .create_index( - Index::create() - .name("idx_gateway_patient_bindings_patient") - .table(Alias::new("gateway_patient_bindings")) - .col(Alias::new("patient_id")) - .to_owned(), - ) - .await?; - - // 外键约束 - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_gpb_gateway") - .from(Alias::new("gateway_patient_bindings"), Alias::new("gateway_id_fk")) - .to(Alias::new("ble_gateways"), Alias::new("id")) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await?; - - manager - .create_foreign_key( - ForeignKey::create() - .name("fk_gpb_patient") - .from(Alias::new("gateway_patient_bindings"), Alias::new("patient_id")) - .to(Alias::new("patients"), Alias::new("id")) - .on_delete(ForeignKeyAction::Cascade) - .to_owned(), - ) - .await?; + // 外键约束(幂等) + let fks = [ + ("fk_gpb_gateway", "ALTER TABLE gateway_patient_bindings ADD CONSTRAINT fk_gpb_gateway FOREIGN KEY (gateway_id_fk) REFERENCES ble_gateways(id) ON DELETE CASCADE"), + ("fk_gpb_patient", "ALTER TABLE gateway_patient_bindings ADD CONSTRAINT fk_gpb_patient FOREIGN KEY (patient_id) REFERENCES patient(id) ON DELETE CASCADE"), + ]; + for (name, sql) in &fks { + let check = format!( + "SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_name = '{name}'" + ); + let result = db.query_one(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + check, + )).await?; + let count: i64 = result.unwrap().try_get_by_index::(0).unwrap_or(0); + if count == 0 { + db.execute_unprepared(sql).await?; + } + } Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { - manager - .drop_table(Table::drop().table(Alias::new("gateway_patient_bindings")).to_owned()) + let db = manager.get_connection(); + db.execute_unprepared("DROP TABLE IF EXISTS gateway_patient_bindings") .await?; - manager - .drop_table(Table::drop().table(Alias::new("ble_gateways")).to_owned()) + db.execute_unprepared("DROP TABLE IF EXISTS ble_gateways") .await?; Ok(()) } diff --git a/crates/erp-server/migration/src/m20260505_000116_seed_missing_health_menus.rs b/crates/erp-server/migration/src/m20260505_000116_seed_missing_health_menus.rs new file mode 100644 index 0000000..a2820bd --- /dev/null +++ b/crates/erp-server/migration/src/m20260505_000116_seed_missing_health_menus.rs @@ -0,0 +1,94 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 批量插入缺失的健康管理菜单(多租户安全) + let menus: &[(&str, &str, &str, &str, i32)] = &[ + ("b0000003-0000-7000-8000-000000000022", "护理计划", "/health/care-plans", "SolutionOutlined", 19), + ("b0000003-0000-7000-8000-000000000023", "班次管理", "/health/shifts", "ClockCircleOutlined", 20), + ("b0000003-0000-7000-8000-000000000024", "用药记录", "/health/medications", "MedicineBoxOutlined", 21), + ("b0000003-0000-7000-8000-000000000025", "BLE 网关", "/health/ble-gateways", "WifiOutlined", 22), + ("b0000003-0000-7000-8000-000000000026", "危急值阈值", "/health/critical-value-thresholds","SafetyCertificateOutlined", 23), + ("b0000003-0000-7000-8000-000000000027", "诊断记录", "/health/diagnoses", "FileSearchOutlined", 24), + ("b0000003-0000-7000-8000-000000000028", "家庭健康代理", "/health/family-proxy", "TeamOutlined", 25), + ("b0000003-0000-7000-8000-000000000029", "知情同意", "/health/consents", "AuditOutlined", 26), + ("b0000003-0000-7000-8000-000000000030", "实时监控", "/health/realtime-monitor", "MonitorOutlined", 27), + ("b0000003-0000-7000-8000-000000000031", "OAuth 合作方", "/health/oauth-clients", "ApiOutlined", 28), + ("b0000003-0000-7000-8000-000000000032", "随访模板管理", "/health/follow-up-templates", "FormOutlined", 29), + ]; + + for &(id, title, path, icon, sort) in menus { + let sql = format!( + r#" + INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, + visible, menu_type, created_at, updated_at, created_by, updated_by, version) + SELECT + '{id}'::uuid, + t.id, + (SELECT m.id FROM menus m WHERE m.path = '/health' AND m.tenant_id = t.id LIMIT 1), + '{title}', + '{path}', + '{icon}', + {sort}, + true, 'page', + NOW(), NOW(), + (SELECT u.id FROM users u WHERE u.tenant_id = t.id LIMIT 1), + (SELECT u.id FROM users u WHERE u.tenant_id = t.id LIMIT 1), + 1 + FROM tenant t + WHERE NOT EXISTS ( + SELECT 1 FROM menus m WHERE m.path = '{path}' AND m.tenant_id = t.id + ) + "# + ); + db.execute_unprepared(&sql).await?; + } + + // 为所有健康模块权限绑定 admin 角色(幂等) + db.execute_unprepared( + r#" + INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_by, updated_by, version) + SELECT r.id, p.id, t.id, r.id, r.id, 1 + FROM tenant t + JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' AND r.deleted_at IS NULL + JOIN permissions p ON p.tenant_id = t.id AND p.code LIKE 'health.%' AND p.deleted_at IS NULL + WHERE NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.permission_id = p.id AND rp.role_id = r.id + ) + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let paths = [ + "/health/care-plans", + "/health/shifts", + "/health/medications", + "/health/ble-gateways", + "/health/critical-value-thresholds", + "/health/diagnoses", + "/health/family-proxy", + "/health/consents", + "/health/realtime-monitor", + "/health/oauth-clients", + "/health/follow-up-templates", + ]; + for path in &paths { + db.execute_unprepared(&format!("DELETE FROM menus WHERE path = '{path}'")) + .await + .ok(); + } + Ok(()) + } +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs index ea310d5..6a69eaf 100644 --- a/crates/erp-server/src/main.rs +++ b/crates/erp-server/src/main.rs @@ -808,20 +808,20 @@ async fn sync_module_permissions( } } - if total_new > 0 { - // 将新权限分配给 admin 角色 - db.execute(sea_orm::Statement::from_sql_and_values( - sea_orm::DatabaseBackend::Postgres, - r#"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) - SELECT r.id, p.id, p.tenant_id, 'all', NOW(), NOW(), $1, $1, NULL, 1 - FROM permissions p - JOIN roles r ON r.code = 'admin' AND r.tenant_id = p.tenant_id AND r.deleted_at IS NULL - WHERE p.tenant_id = $2 - ON CONFLICT DO NOTHING"#, - [system_user_id.into(), tenant_id.into()], - )).await?; + // 每次启动都确保 admin 角色拥有所有模块权限(防止权限-角色关联缺失) + db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT r.id, p.id, p.tenant_id, 'all', NOW(), NOW(), $1, $1, NULL, 1 + FROM permissions p + JOIN roles r ON r.code = 'admin' AND r.tenant_id = p.tenant_id AND r.deleted_at IS NULL + WHERE p.tenant_id = $2 + ON CONFLICT DO NOTHING"#, + [system_user_id.into(), tenant_id.into()], + )).await?; - tracing::info!(total_new, "Module permissions synced to database"); + if total_new > 0 { + tracing::info!(total_new, "New module permissions synced and bound to admin role"); } Ok(())