diff --git a/crates/erp-health/src/dto/mod.rs b/crates/erp-health/src/dto/mod.rs index 6245d55..f68e66e 100644 --- a/crates/erp-health/src/dto/mod.rs +++ b/crates/erp-health/src/dto/mod.rs @@ -14,6 +14,7 @@ pub mod follow_up_template_dto; pub mod health_data_dto; pub mod patient_dto; pub mod points_dto; +pub mod shift_dto; pub mod stats_dto; #[derive(Debug, serde::Deserialize, utoipa::ToSchema)] diff --git a/crates/erp-health/src/dto/shift_dto.rs b/crates/erp-health/src/dto/shift_dto.rs new file mode 100644 index 0000000..dcc02f4 --- /dev/null +++ b/crates/erp-health/src/dto/shift_dto.rs @@ -0,0 +1,164 @@ +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// Shift DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShiftResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub shift_date: NaiveDate, + pub period: String, + pub nurse_id: Option, + pub status: String, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub created_by: Option, + pub updated_by: Option, + pub version: i32, + /// 班次内患者分配摘要 + pub patient_count: Option, + pub critical_count: Option, + pub attention_count: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateShiftReq { + pub shift_date: NaiveDate, + pub period: String, + pub nurse_id: Option, + pub notes: Option, +} + +impl CreateShiftReq { + pub fn sanitize(&mut self) { + self.period = erp_core::sanitize::sanitize_string(&self.period); + self.notes = self.notes.take().map(|n| erp_core::sanitize::sanitize_string(&n)); + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateShiftReq { + pub nurse_id: Option, + pub status: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateShiftWithVersion { + pub version: i32, + pub data: UpdateShiftReq, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListShiftsParams { + pub shift_date: Option, + pub period: Option, + pub nurse_id: Option, + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +// --------------------------------------------------------------------------- +// PatientAssignment DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatientAssignmentResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub shift_id: Uuid, + pub patient_id: Uuid, + pub care_level: String, + pub notes: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePatientAssignmentReq { + pub patient_id: Uuid, + pub care_level: Option, + pub notes: Option, +} + +impl CreatePatientAssignmentReq { + pub fn sanitize(&mut self) { + if let Some(ref mut cl) = self.care_level { + *cl = erp_core::sanitize::sanitize_string(cl); + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePatientAssignmentReq { + pub care_level: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePatientAssignmentWithVersion { + pub version: i32, + pub data: UpdatePatientAssignmentReq, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchAssignReq { + pub patient_ids: Vec, + pub care_level: Option, +} + +// --------------------------------------------------------------------------- +// HandoffLog DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HandoffLogResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub from_shift_id: Uuid, + pub to_shift_id: Uuid, + pub patient_id: Uuid, + pub notes: Option, + pub pending_items: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub version: i32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateHandoffReq { + pub from_shift_id: Uuid, + pub to_shift_id: Uuid, + pub patient_id: Uuid, + pub notes: Option, + pub pending_items: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListHandoffParams { + pub to_shift_id: Option, + pub from_shift_id: Option, + pub patient_id: Option, + pub page: Option, + pub page_size: Option, +} diff --git a/crates/erp-health/src/entity/handoff_log.rs b/crates/erp-health/src/entity/handoff_log.rs new file mode 100644 index 0000000..258809a --- /dev/null +++ b/crates/erp-health/src/entity/handoff_log.rs @@ -0,0 +1,51 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[sea_orm(table_name = "shift_handoff_log")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub from_shift_id: Uuid, + pub to_shift_id: Uuid, + pub patient_id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub pending_items: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::shift::Entity", + from = "Column::FromShiftId", + to = "super::shift::Column::Id" + )] + FromShift, + #[sea_orm( + belongs_to = "super::shift::Entity", + from = "Column::ToShiftId", + to = "super::shift::Column::Id" + )] + ToShift, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::FromShift.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/mod.rs b/crates/erp-health/src/entity/mod.rs index 4b54ce2..18cc047 100644 --- a/crates/erp-health/src/entity/mod.rs +++ b/crates/erp-health/src/entity/mod.rs @@ -46,5 +46,8 @@ pub mod vital_signs; pub mod care_plan; pub mod care_plan_item; pub mod care_plan_outcome; +pub mod shift; +pub mod patient_assignment; +pub mod handoff_log; pub mod vital_signs_daily; pub mod vital_signs_hourly; diff --git a/crates/erp-health/src/entity/patient_assignment.rs b/crates/erp-health/src/entity/patient_assignment.rs new file mode 100644 index 0000000..2a75fec --- /dev/null +++ b/crates/erp-health/src/entity/patient_assignment.rs @@ -0,0 +1,55 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[sea_orm(table_name = "patient_assignments")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub shift_id: Uuid, + pub patient_id: Uuid, + pub care_level: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::shift::Entity", + from = "Column::ShiftId", + to = "super::shift::Column::Id" + )] + Shift, + #[sea_orm( + belongs_to = "super::patient::Entity", + from = "Column::PatientId", + to = "super::patient::Column::Id" + )] + Patient, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Shift.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Patient.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/entity/shift.rs b/crates/erp-health/src/entity/shift.rs new file mode 100644 index 0000000..6b39f53 --- /dev/null +++ b/crates/erp-health/src/entity/shift.rs @@ -0,0 +1,53 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[sea_orm(table_name = "shifts")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub shift_date: chrono::NaiveDate, + /// 班次时段: morning / afternoon / night + pub period: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub nurse_id: Option, + /// 班次状态: scheduled / in_progress / completed + pub status: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::patient_assignment::Entity")] + PatientAssignments, + #[sea_orm(has_many = "super::handoff_log::Entity")] + HandoffFrom, + #[sea_orm(has_many = "super::handoff_log::Entity")] + HandoffTo, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::PatientAssignments.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::HandoffFrom.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-health/src/error.rs b/crates/erp-health/src/error.rs index e087617..e9dc48e 100644 --- a/crates/erp-health/src/error.rs +++ b/crates/erp-health/src/error.rs @@ -92,6 +92,15 @@ pub enum HealthError { #[error("护理计划预后不存在")] CarePlanOutcomeNotFound, + #[error("班次不存在")] + ShiftNotFound, + + #[error("患者分配不存在")] + PatientAssignmentNotFound, + + #[error("交接记录不存在")] + HandoffLogNotFound, + #[error("状态转换无效: {0}")] InvalidStatusTransition(String), @@ -133,7 +142,10 @@ impl From for AppError { | HealthError::MedicationReminderNotFound | HealthError::CarePlanNotFound | HealthError::CarePlanItemNotFound - | HealthError::CarePlanOutcomeNotFound => AppError::NotFound(err.to_string()), + | HealthError::CarePlanOutcomeNotFound + | HealthError::ShiftNotFound + | HealthError::PatientAssignmentNotFound + | HealthError::HandoffLogNotFound => AppError::NotFound(err.to_string()), HealthError::ScheduleFull => AppError::Validation(err.to_string()), HealthError::InvalidStatusTransition(s) => AppError::Validation(s), HealthError::VersionMismatch => AppError::VersionMismatch, diff --git a/crates/erp-health/src/handler/mod.rs b/crates/erp-health/src/handler/mod.rs index 5ce59aa..989b645 100644 --- a/crates/erp-health/src/handler/mod.rs +++ b/crates/erp-health/src/handler/mod.rs @@ -23,4 +23,5 @@ pub mod health_data_handler; pub mod patient_handler; pub mod points_handler; pub mod stats_handler; +pub mod shift_handler; pub mod vital_signs_daily_handler; diff --git a/crates/erp-health/src/handler/shift_handler.rs b/crates/erp-health/src/handler/shift_handler.rs new file mode 100644 index 0000000..acf7af2 --- /dev/null +++ b/crates/erp-health/src/handler/shift_handler.rs @@ -0,0 +1,207 @@ +use axum::extract::{FromRef, Json, Path, Query, State}; +use axum::Extension; +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; +use uuid::Uuid; + +use crate::dto::shift_dto::*; +use crate::service::shift_service; +use crate::state::HealthState; + +#[derive(Debug, serde::Deserialize)] +pub struct PaginationParams { + pub page: Option, + pub page_size: Option, +} + +// --------------------------------------------------------------------------- +// Shift +// --------------------------------------------------------------------------- + +pub async fn list_shifts( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.list")?; + let result = shift_service::list_shifts(&state, ctx.tenant_id, ¶ms).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn get_shift( + State(state): State, + Extension(ctx): Extension, + Path(shift_id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.list")?; + let result = shift_service::get_shift(&state, ctx.tenant_id, shift_id).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn create_shift( + State(state): State, + Extension(ctx): Extension, + Json(mut body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.manage")?; + body.sanitize(); + let result = shift_service::create_shift(&state, ctx.tenant_id, Some(ctx.user_id), body).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn update_shift( + State(state): State, + Extension(ctx): Extension, + Path(shift_id): Path, + Json(body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.manage")?; + let result = shift_service::update_shift(&state, ctx.tenant_id, shift_id, Some(ctx.user_id), body).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn delete_shift( + State(state): State, + Extension(ctx): Extension, + Path(shift_id): Path, + Query(params): Query, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.manage")?; + shift_service::delete_shift(&state, ctx.tenant_id, shift_id, Some(ctx.user_id), params.version).await?; + Ok(Json(ApiResponse::ok(()))) +} + +// --------------------------------------------------------------------------- +// PatientAssignment +// --------------------------------------------------------------------------- + +pub async fn list_assignments( + State(state): State, + Extension(ctx): Extension, + Path(shift_id): Path, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.list")?; + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let result = shift_service::list_assignments(&state, ctx.tenant_id, shift_id, page, page_size).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn create_assignment( + State(state): State, + Extension(ctx): Extension, + Path(shift_id): Path, + Json(mut body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.manage")?; + body.sanitize(); + let result = shift_service::create_assignment(&state, ctx.tenant_id, shift_id, Some(ctx.user_id), body).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn batch_assign( + State(state): State, + Extension(ctx): Extension, + Path(shift_id): Path, + Json(body): Json, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.manage")?; + let result = shift_service::batch_assign(&state, ctx.tenant_id, shift_id, Some(ctx.user_id), body).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn update_assignment( + State(state): State, + Extension(ctx): Extension, + Path((shift_id, assignment_id)): Path<(Uuid, Uuid)>, + Json(body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.manage")?; + let result = shift_service::update_assignment(&state, ctx.tenant_id, shift_id, assignment_id, Some(ctx.user_id), body).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn delete_assignment( + State(state): State, + Extension(ctx): Extension, + Path((shift_id, assignment_id)): Path<(Uuid, Uuid)>, + Query(params): Query, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.manage")?; + shift_service::delete_assignment(&state, ctx.tenant_id, shift_id, assignment_id, Some(ctx.user_id), params.version).await?; + Ok(Json(ApiResponse::ok(()))) +} + +// --------------------------------------------------------------------------- +// HandoffLog +// --------------------------------------------------------------------------- + +pub async fn list_handoffs( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.list")?; + let result = shift_service::list_handoffs(&state, ctx.tenant_id, ¶ms).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn create_handoff( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.shifts.manage")?; + let result = shift_service::create_handoff(&state, ctx.tenant_id, Some(ctx.user_id), body).await?; + Ok(Json(ApiResponse::ok(result))) +} diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 466abf5..0147d4d 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -9,7 +9,7 @@ use crate::handler::{ action_inbox_handler, alert_handler, alert_rule_handler, appointment_handler, article_category_handler, article_handler, article_tag_handler, care_plan_handler, consultation_handler, consent_handler, critical_alert_handler, critical_value_threshold_handler, daily_monitoring_handler, device_handler, device_reading_handler, diagnosis_handler, doctor_handler, follow_up_handler, follow_up_template_handler, - health_data_handler, medication_record_handler, medication_reminder_handler, patient_handler, points_handler, stats_handler, + health_data_handler, medication_record_handler, medication_reminder_handler, patient_handler, points_handler, shift_handler, stats_handler, vital_signs_daily_handler, }; @@ -852,6 +852,38 @@ impl HealthModule { axum::routing::put(care_plan_handler::update_care_plan_outcome) .delete(care_plan_handler::delete_care_plan_outcome), ) + // 班次管理 + .route( + "/health/shifts", + axum::routing::get(shift_handler::list_shifts) + .post(shift_handler::create_shift), + ) + .route( + "/health/shifts/{shift_id}", + axum::routing::get(shift_handler::get_shift) + .put(shift_handler::update_shift) + .delete(shift_handler::delete_shift), + ) + .route( + "/health/shifts/{shift_id}/assignments", + axum::routing::get(shift_handler::list_assignments) + .post(shift_handler::create_assignment), + ) + .route( + "/health/shifts/{shift_id}/assignments/batch", + axum::routing::post(shift_handler::batch_assign), + ) + .route( + "/health/shifts/{shift_id}/assignments/{assignment_id}", + axum::routing::put(shift_handler::update_assignment) + .delete(shift_handler::delete_assignment), + ) + // 交接记录 + .route( + "/health/handoff-logs", + axum::routing::get(shift_handler::list_handoffs) + .post(shift_handler::create_handoff), + ) } } @@ -1285,6 +1317,19 @@ impl ErpModule for HealthModule { description: "创建/编辑/删除护理计划、条目和预后测量".into(), module: "health".into(), }, + // 班次管理 + PermissionDescriptor { + code: "health.shifts.list".into(), + name: "查看班次".into(), + description: "查看班次列表、患者分配和交接记录".into(), + module: "health".into(), + }, + PermissionDescriptor { + code: "health.shifts.manage".into(), + name: "管理班次".into(), + description: "创建/编辑班次、分配患者、创建交接记录".into(), + module: "health".into(), + }, ] } diff --git a/crates/erp-health/src/service/mod.rs b/crates/erp-health/src/service/mod.rs index 260ae21..7a7a26a 100644 --- a/crates/erp-health/src/service/mod.rs +++ b/crates/erp-health/src/service/mod.rs @@ -28,6 +28,7 @@ pub mod masking; pub mod patient_service; pub mod points_service; pub mod seed; +pub mod shift_service; pub mod stats_service; pub mod trend_service; pub mod trend_stats; diff --git a/crates/erp-health/src/service/shift_service.rs b/crates/erp-health/src/service/shift_service.rs new file mode 100644 index 0000000..77208a3 --- /dev/null +++ b/crates/erp-health/src/service/shift_service.rs @@ -0,0 +1,707 @@ +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::DomainEvent; +use erp_core::types::PaginatedResponse; + +use crate::dto::shift_dto::*; +use crate::entity::{handoff_log, patient, patient_assignment, shift}; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +// --------------------------------------------------------------------------- +// Shift CRUD +// --------------------------------------------------------------------------- + +pub async fn list_shifts( + state: &HealthState, + tenant_id: Uuid, + params: &ListShiftsParams, +) -> HealthResult> { + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = shift::Entity::find() + .filter(shift::Column::TenantId.eq(tenant_id)) + .filter(shift::Column::DeletedAt.is_null()); + + if let Some(d) = params.shift_date { + query = query.filter(shift::Column::ShiftDate.eq(d)); + } + if let Some(ref p) = params.period { + query = query.filter(shift::Column::Period.eq(p.as_str())); + } + if let Some(nid) = params.nurse_id { + query = query.filter(shift::Column::NurseId.eq(nid)); + } + if let Some(ref s) = params.status { + query = query.filter(shift::Column::Status.eq(s.as_str())); + } + + let total: u64 = query.clone().count(&state.db).await?; + let rows: Vec = query + .order_by_desc(shift::Column::ShiftDate) + .order_by_asc(shift::Column::Period) + .order_by_desc(shift::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + + // 统计每个班次的患者分配数量(按 care_level 分组) + let mut data = Vec::with_capacity(rows.len()); + for m in rows { + let (patient_count, critical_count, attention_count) = + count_patients_by_care_level(state, m.id).await?; + data.push(shift_to_resp(m, Some(patient_count), Some(critical_count), Some(attention_count))); + } + + Ok(PaginatedResponse { + data, + total, + page, + page_size, + total_pages, + }) +} + +pub async fn get_shift( + state: &HealthState, + tenant_id: Uuid, + shift_id: Uuid, +) -> HealthResult { + let m = find_shift(state, tenant_id, shift_id).await?; + let (patient_count, critical_count, attention_count) = + count_patients_by_care_level(state, m.id).await?; + Ok(shift_to_resp(m, Some(patient_count), Some(critical_count), Some(attention_count))) +} + +pub async fn create_shift( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateShiftReq, +) -> HealthResult { + validate_period(&req.period)?; + validate_shift_status("scheduled")?; + + let now = Utc::now(); + let active = shift::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + shift_date: Set(req.shift_date), + period: Set(req.period), + nurse_id: Set(req.nurse_id), + status: Set("scheduled".to_string()), + notes: Set(req.notes), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let m = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "shift.created", "shift").with_resource_id(m.id), + &state.db, + ) + .await; + + state + .event_bus + .publish( + DomainEvent::new( + "shift.created", + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "shift_id": m.id, + "shift_date": m.shift_date.to_string(), + "period": m.period, + "nurse_id": m.nurse_id, + })), + ), + &state.db, + ) + .await; + + Ok(shift_to_resp(m, Some(0), Some(0), Some(0))) +} + +pub async fn update_shift( + state: &HealthState, + tenant_id: Uuid, + shift_id: Uuid, + operator_id: Option, + req: UpdateShiftWithVersion, +) -> HealthResult { + let existing = find_shift(state, tenant_id, shift_id).await?; + let next_ver = check_version(req.version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: shift::ActiveModel = existing.into(); + let now = Utc::now(); + + if let Some(v) = req.data.nurse_id { + active.nurse_id = Set(Some(v)); + } + if let Some(ref v) = req.data.status { + validate_shift_status(v)?; + active.status = Set(v.clone()); + } + if req.data.notes.is_some() { + active.notes = Set(req.data.notes); + } + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let m = active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "shift.updated", "shift").with_resource_id(m.id), + &state.db, + ) + .await; + + state + .event_bus + .publish( + DomainEvent::new( + "shift.updated", + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "shift_id": m.id, + "status": m.status, + "nurse_id": m.nurse_id, + })), + ), + &state.db, + ) + .await; + + let (patient_count, critical_count, attention_count) = + count_patients_by_care_level(state, m.id).await?; + Ok(shift_to_resp(m, Some(patient_count), Some(critical_count), Some(attention_count))) +} + +pub async fn delete_shift( + state: &HealthState, + tenant_id: Uuid, + shift_id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + let existing = find_shift(state, tenant_id, shift_id).await?; + let next_ver = check_version(version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let now = Utc::now(); + let mut active: shift::ActiveModel = existing.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "shift.deleted", "shift").with_resource_id(shift_id), + &state.db, + ) + .await; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// PatientAssignment CRUD +// --------------------------------------------------------------------------- + +pub async fn list_assignments( + state: &HealthState, + tenant_id: Uuid, + shift_id: Uuid, + page: u64, + page_size: u64, +) -> HealthResult> { + let _shift = find_shift(state, tenant_id, shift_id).await?; + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let query = patient_assignment::Entity::find() + .filter(patient_assignment::Column::TenantId.eq(tenant_id)) + .filter(patient_assignment::Column::ShiftId.eq(shift_id)) + .filter(patient_assignment::Column::DeletedAt.is_null()); + + let total: u64 = query.clone().count(&state.db).await?; + let rows: Vec = query + .order_by_desc(patient_assignment::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = rows.into_iter().map(assignment_to_resp).collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size, + total_pages, + }) +} + +pub async fn create_assignment( + state: &HealthState, + tenant_id: Uuid, + shift_id: Uuid, + operator_id: Option, + req: CreatePatientAssignmentReq, +) -> HealthResult { + // 验证班次存在 + find_shift(state, tenant_id, shift_id).await?; + + // 验证患者存在 + patient::Entity::find() + .filter(patient::Column::Id.eq(req.patient_id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + // 验证 care_level + let care_level = req.care_level.unwrap_or_else(|| "routine".to_string()); + validate_care_level(&care_level)?; + + let now = Utc::now(); + let active = patient_assignment::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + shift_id: Set(shift_id), + patient_id: Set(req.patient_id), + care_level: Set(care_level), + notes: Set(req.notes), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let m = active.insert(&state.db).await?; + + Ok(assignment_to_resp(m)) +} + +pub async fn batch_assign( + state: &HealthState, + tenant_id: Uuid, + shift_id: Uuid, + operator_id: Option, + req: BatchAssignReq, +) -> HealthResult> { + // 验证班次存在 + find_shift(state, tenant_id, shift_id).await?; + + let care_level = req.care_level.unwrap_or_else(|| "routine".to_string()); + validate_care_level(&care_level)?; + + let mut results = Vec::with_capacity(req.patient_ids.len()); + + for pid in req.patient_ids { + // 验证患者存在 + let patient_exists = patient::Entity::find() + .filter(patient::Column::Id.eq(pid)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .is_some(); + + if !patient_exists { + return Err(HealthError::PatientNotFound); + } + + let now = Utc::now(); + let active = patient_assignment::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + shift_id: Set(shift_id), + patient_id: Set(pid), + care_level: Set(care_level.clone()), + notes: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let m = active.insert(&state.db).await?; + results.push(assignment_to_resp(m)); + } + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "shift.batch_assigned", "patient_assignment") + .with_resource_id(shift_id), + &state.db, + ) + .await; + + Ok(results) +} + +pub async fn update_assignment( + state: &HealthState, + tenant_id: Uuid, + shift_id: Uuid, + assignment_id: Uuid, + operator_id: Option, + req: UpdatePatientAssignmentWithVersion, +) -> HealthResult { + // 验证班次存在 + let _shift = find_shift(state, tenant_id, shift_id).await?; + + let existing = patient_assignment::Entity::find_by_id(assignment_id) + .one(&state.db) + .await? + .ok_or(HealthError::PatientAssignmentNotFound)?; + + if existing.tenant_id != tenant_id || existing.shift_id != shift_id { + return Err(HealthError::PatientAssignmentNotFound); + } + + let next_ver = check_version(req.version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: patient_assignment::ActiveModel = existing.into(); + let now = Utc::now(); + + if let Some(ref v) = req.data.care_level { + validate_care_level(v)?; + active.care_level = Set(v.clone()); + } + if req.data.notes.is_some() { + active.notes = Set(req.data.notes); + } + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let m = active.update(&state.db).await?; + Ok(assignment_to_resp(m)) +} + +pub async fn delete_assignment( + state: &HealthState, + tenant_id: Uuid, + shift_id: Uuid, + assignment_id: Uuid, + operator_id: Option, + version: i32, +) -> HealthResult<()> { + let _shift = find_shift(state, tenant_id, shift_id).await?; + + let existing = patient_assignment::Entity::find_by_id(assignment_id) + .one(&state.db) + .await? + .ok_or(HealthError::PatientAssignmentNotFound)?; + + if existing.tenant_id != tenant_id || existing.shift_id != shift_id { + return Err(HealthError::PatientAssignmentNotFound); + } + + let next_ver = check_version(version, existing.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let now = Utc::now(); + let mut active: patient_assignment::ActiveModel = existing.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + active.update(&state.db).await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// HandoffLog CRUD +// --------------------------------------------------------------------------- + +pub async fn list_handoffs( + state: &HealthState, + tenant_id: Uuid, + params: &ListHandoffParams, +) -> HealthResult> { + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = handoff_log::Entity::find() + .filter(handoff_log::Column::TenantId.eq(tenant_id)) + .filter(handoff_log::Column::DeletedAt.is_null()); + + if let Some(fid) = params.from_shift_id { + query = query.filter(handoff_log::Column::FromShiftId.eq(fid)); + } + if let Some(tid) = params.to_shift_id { + query = query.filter(handoff_log::Column::ToShiftId.eq(tid)); + } + if let Some(pid) = params.patient_id { + query = query.filter(handoff_log::Column::PatientId.eq(pid)); + } + + let total: u64 = query.clone().count(&state.db).await?; + let rows: Vec = query + .order_by_desc(handoff_log::Column::CreatedAt) + .limit(limit) + .offset(offset) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = rows.into_iter().map(handoff_to_resp).collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size, + total_pages, + }) +} + +pub async fn create_handoff( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateHandoffReq, +) -> HealthResult { + // 验证 from_shift 存在 + find_shift(state, tenant_id, req.from_shift_id).await?; + // 验证 to_shift 存在 + find_shift(state, tenant_id, req.to_shift_id).await?; + // 验证患者存在 + patient::Entity::find() + .filter(patient::Column::Id.eq(req.patient_id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + let now = Utc::now(); + let active = handoff_log::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + from_shift_id: Set(req.from_shift_id), + to_shift_id: Set(req.to_shift_id), + patient_id: Set(req.patient_id), + notes: Set(req.notes), + pending_items: Set(req.pending_items), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let m = active.insert(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "shift.handoff_created", "handoff_log") + .with_resource_id(m.id), + &state.db, + ) + .await; + + state + .event_bus + .publish( + DomainEvent::new( + "shift.handoff_created", + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "handoff_id": m.id, + "from_shift_id": m.from_shift_id, + "to_shift_id": m.to_shift_id, + "patient_id": m.patient_id, + })), + ), + &state.db, + ) + .await; + + Ok(handoff_to_resp(m)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// 按 shift_id 查找班次,不存在或已删除返回 ShiftNotFound +pub async fn find_shift( + state: &HealthState, + tenant_id: Uuid, + shift_id: Uuid, +) -> HealthResult { + shift::Entity::find_by_id(shift_id) + .one(&state.db) + .await? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or(HealthError::ShiftNotFound) +} + +/// 统计指定班次的患者分配数量(按 care_level 分组) +async fn count_patients_by_care_level( + state: &HealthState, + shift_id: Uuid, +) -> HealthResult<(i64, i64, i64)> { + use sea_orm::FromQueryResult; + use std::collections::HashMap; + + #[derive(Debug, FromQueryResult)] + struct CareLevelCount { + care_level: String, + count: i64, + } + + let counts: Vec = CareLevelCount::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "SELECT care_level, CAST(COUNT(*) AS BIGINT) as count FROM patient_assignments \ + WHERE shift_id = $1 AND deleted_at IS NULL \ + GROUP BY care_level", + [shift_id.into()], + ), + ) + .all(&state.db) + .await?; + + let map: HashMap = counts + .into_iter() + .map(|c| (c.care_level, c.count)) + .collect(); + + let patient_count = map.values().sum(); + let critical_count = map.get("critical").copied().unwrap_or(0); + let attention_count = map.get("attention").copied().unwrap_or(0); + + Ok((patient_count, critical_count, attention_count)) +} + +// --------------------------------------------------------------------------- +// Conversion helpers +// --------------------------------------------------------------------------- + +fn shift_to_resp( + m: shift::Model, + patient_count: Option, + critical_count: Option, + attention_count: Option, +) -> ShiftResp { + ShiftResp { + id: m.id, + tenant_id: m.tenant_id, + shift_date: m.shift_date, + period: m.period, + nurse_id: m.nurse_id, + status: m.status, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + created_by: m.created_by, + updated_by: m.updated_by, + version: m.version, + patient_count, + critical_count, + attention_count, + } +} + +fn assignment_to_resp(m: patient_assignment::Model) -> PatientAssignmentResp { + PatientAssignmentResp { + id: m.id, + tenant_id: m.tenant_id, + shift_id: m.shift_id, + patient_id: m.patient_id, + care_level: m.care_level, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +fn handoff_to_resp(m: handoff_log::Model) -> HandoffLogResp { + HandoffLogResp { + id: m.id, + tenant_id: m.tenant_id, + from_shift_id: m.from_shift_id, + to_shift_id: m.to_shift_id, + patient_id: m.patient_id, + notes: m.notes, + pending_items: m.pending_items, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} + +// --------------------------------------------------------------------------- +// Validators +// --------------------------------------------------------------------------- + +/// 验证班次时段: morning / afternoon / night +pub fn validate_period(period: &str) -> HealthResult<()> { + let valid = ["morning", "afternoon", "night"]; + if valid.contains(&period) { + Ok(()) + } else { + Err(HealthError::Validation(format!( + "period 必须为以下之一: {}", + valid.join(", ") + ))) + } +} + +/// 验证班次状态: scheduled / in_progress / completed / cancelled +pub fn validate_shift_status(status: &str) -> HealthResult<()> { + let valid = ["scheduled", "in_progress", "completed", "cancelled"]; + if valid.contains(&status) { + Ok(()) + } else { + Err(HealthError::Validation(format!( + "status 必须为以下之一: {}", + valid.join(", ") + ))) + } +} + +/// 验证护理级别: routine / attention / critical +pub fn validate_care_level(level: &str) -> HealthResult<()> { + let valid = ["routine", "attention", "critical"]; + if valid.contains(&level) { + Ok(()) + } else { + Err(HealthError::Validation(format!( + "care_level 必须为以下之一: {}", + valid.join(", ") + ))) + } +} diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index baef196..b0d292b 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -111,6 +111,7 @@ mod m20260504_000108_alter_vital_signs_hourly_add_soft_delete; mod m20260504_000109_add_missing_fk_constraints; mod m20260504_000110_alter_critical_alerts_version_i32; mod m20260505_000111_create_care_plan; +mod m20260505_000112_create_shift_management; pub struct Migrator; @@ -229,6 +230,7 @@ impl MigratorTrait for Migrator { Box::new(m20260504_000109_add_missing_fk_constraints::Migration), Box::new(m20260504_000110_alter_critical_alerts_version_i32::Migration), Box::new(m20260505_000111_create_care_plan::Migration), + Box::new(m20260505_000112_create_shift_management::Migration), ] } } 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 new file mode 100644 index 0000000..04f0d19 --- /dev/null +++ b/crates/erp-server/migration/src/m20260505_000112_create_shift_management.rs @@ -0,0 +1,250 @@ +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> { + // 班次表 + 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?; + + 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?; + + 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?; + + // 患者分配表 + 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?; + + manager + .create_foreign_key( + &mut ForeignKey::create() + .name("fk_patient_assignments_shift") + .from(PatientAssignment::Table, PatientAssignment::ShiftId) + .to(Shift::Table, Shift::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .await?; + + 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( + &mut 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( + &mut 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?; + + 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?; + 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, +}