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 { tracing::info!(tenant = %tenant_id, "创建班次"); 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 { tracing::info!(tenant = %tenant_id, shift = %shift_id, "更新班次"); 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<()> { tracing::info!(tenant = %tenant_id, shift = %shift_id, "删除班次"); 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> { tracing::info!(tenant = %tenant_id, shift = %shift_id, count = req.patient_ids.len(), "批量分配患者"); // 验证班次存在 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_assignment \ 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(", ") ))) } }