Files
hms/crates/erp-health/src/service/care_plan_service.rs
iven ef422f354d
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
feat(health): 护理计划实体与服务 — Phase 1 关怀引擎 MVP 第一步
新增护理计划(Care Plan)完整 CRUD:3 张表(care_plans / care_plan_items /
care_plan_outcomes)、3 个 SeaORM Entity、15 个 API 端点、4 个事件常量、
2 个权限码。支持透析/慢性/预防/康复计划类型,条目分干预/监测/目标/教育四类,
预后测量含基线/目标/当前值追踪。
2026-05-04 18:40:22 +08:00

648 lines
19 KiB
Rust

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::care_plan_dto::*;
use crate::entity::{care_plan, care_plan_item, care_plan_outcome, patient};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// CarePlan CRUD
// ---------------------------------------------------------------------------
pub async fn list_care_plans(
state: &HealthState,
tenant_id: Uuid,
params: &ListCarePlansParams,
) -> HealthResult<PaginatedResponse<CarePlanResp>> {
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 = care_plan::Entity::find()
.filter(care_plan::Column::TenantId.eq(tenant_id))
.filter(care_plan::Column::DeletedAt.is_null());
if let Some(pid) = params.patient_id {
query = query.filter(care_plan::Column::PatientId.eq(pid));
}
if let Some(ref pt) = params.plan_type {
query = query.filter(care_plan::Column::PlanType.eq(pt.as_str()));
}
if let Some(ref st) = params.status {
query = query.filter(care_plan::Column::Status.eq(st.as_str()));
}
let total: u64 = query.clone().count(&state.db).await?;
let rows: Vec<care_plan::Model> = query
.order_by_desc(care_plan::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(plan_to_resp).collect();
Ok(PaginatedResponse {
data,
total,
page,
page_size,
total_pages,
})
}
pub async fn get_care_plan(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
) -> HealthResult<CarePlanResp> {
let m = find_plan(state, tenant_id, plan_id).await?;
Ok(plan_to_resp(m))
}
pub async fn create_care_plan(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateCarePlanReq,
) -> HealthResult<CarePlanResp> {
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)?;
validate_plan_type(&req.plan_type)?;
let now = Utc::now();
let active = care_plan::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id),
plan_type: Set(req.plan_type),
status: Set("draft".to_string()),
title: Set(req.title),
goals: Set(req.goals.unwrap_or(serde_json::json!([]))),
start_date: Set(req.start_date),
end_date: Set(req.end_date),
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, "care_plan.created", "care_plan")
.with_resource_id(m.id),
&state.db,
)
.await;
state
.event_bus
.publish(
DomainEvent::new(
crate::event::CARE_PLAN_CREATED,
tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"plan_id": m.id, "patient_id": m.patient_id,
"plan_type": m.plan_type, "title": m.title,
})),
),
&state.db,
)
.await;
Ok(plan_to_resp(m))
}
pub async fn update_care_plan(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
operator_id: Option<Uuid>,
req: UpdateCarePlanWithVersion,
) -> HealthResult<CarePlanResp> {
let existing = find_plan(state, tenant_id, plan_id).await?;
let _old_status = existing.status.clone(); // 用于后续事件类型判断
let next_ver = check_version(req.version, existing.version)
.map_err(|_| HealthError::VersionMismatch)?;
let old_status = existing.status.clone();
let mut active: care_plan::ActiveModel = existing.into();
let now = Utc::now();
if let Some(v) = req.data.plan_type {
validate_plan_type(&v)?;
active.plan_type = Set(v);
}
if let Some(v) = req.data.title {
active.title = Set(v);
}
if let Some(v) = req.data.status {
validate_plan_status(&v)?;
active.status = Set(v);
}
if let Some(v) = req.data.goals {
active.goals = Set(v);
}
if req.data.start_date.is_some() {
active.start_date = Set(req.data.start_date);
}
if req.data.end_date.is_some() {
active.end_date = Set(req.data.end_date);
}
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, "care_plan.updated", "care_plan")
.with_resource_id(m.id),
&state.db,
)
.await;
let event_type = match m.status.as_str() {
"active" => crate::event::CARE_PLAN_ACTIVATED,
"completed" => crate::event::CARE_PLAN_COMPLETED,
_ => crate::event::CARE_PLAN_UPDATED,
};
state
.event_bus
.publish(
DomainEvent::new(
event_type,
tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"plan_id": m.id, "patient_id": m.patient_id,
"status": m.status,
})),
),
&state.db,
)
.await;
Ok(plan_to_resp(m))
}
pub async fn delete_care_plan(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
operator_id: Option<Uuid>,
version: i32,
) -> HealthResult<()> {
let existing = find_plan(state, tenant_id, plan_id).await?;
let next_ver = check_version(version, existing.version)
.map_err(|_| HealthError::VersionMismatch)?;
let now = Utc::now();
let mut active: care_plan::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, "care_plan.deleted", "care_plan")
.with_resource_id(plan_id),
&state.db,
)
.await;
Ok(())
}
// ---------------------------------------------------------------------------
// CarePlanItem CRUD
// ---------------------------------------------------------------------------
pub async fn list_care_plan_items(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<CarePlanItemResp>> {
let _plan = find_plan(state, tenant_id, plan_id).await?;
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = care_plan_item::Entity::find()
.filter(care_plan_item::Column::TenantId.eq(tenant_id))
.filter(care_plan_item::Column::PlanId.eq(plan_id))
.filter(care_plan_item::Column::DeletedAt.is_null());
let total: u64 = query.clone().count(&state.db).await?;
let rows: Vec<care_plan_item::Model> = query
.order_by_asc(care_plan_item::Column::SortOrder)
.order_by_desc(care_plan_item::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(item_to_resp).collect();
Ok(PaginatedResponse {
data,
total,
page,
page_size,
total_pages,
})
}
pub async fn create_care_plan_item(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
operator_id: Option<Uuid>,
req: CreateCarePlanItemReq,
) -> HealthResult<CarePlanItemResp> {
let _plan = find_plan(state, tenant_id, plan_id).await?;
validate_item_type(&req.item_type)?;
let now = Utc::now();
let active = care_plan_item::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
plan_id: Set(plan_id),
item_type: Set(req.item_type),
title: Set(req.title),
description: Set(req.description),
status: Set("pending".to_string()),
schedule: Set(req.schedule),
sort_order: Set(req.sort_order),
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(item_to_resp(m))
}
pub async fn update_care_plan_item(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
item_id: Uuid,
operator_id: Option<Uuid>,
req: UpdateCarePlanItemWithVersion,
) -> HealthResult<CarePlanItemResp> {
let _plan = find_plan(state, tenant_id, plan_id).await?;
let existing = care_plan_item::Entity::find_by_id(item_id)
.one(&state.db)
.await?
.ok_or(HealthError::CarePlanItemNotFound)?;
if existing.tenant_id != tenant_id || existing.plan_id != plan_id {
return Err(HealthError::CarePlanItemNotFound);
}
let next_ver = check_version(req.version, existing.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: care_plan_item::ActiveModel = existing.into();
let now = Utc::now();
if let Some(v) = req.data.item_type {
validate_item_type(&v)?;
active.item_type = Set(v);
}
if let Some(v) = req.data.title {
active.title = Set(v);
}
if let Some(v) = req.data.status {
active.status = Set(v);
}
if req.data.description.is_some() {
active.description = Set(req.data.description);
}
if req.data.schedule.is_some() {
active.schedule = Set(req.data.schedule);
}
if req.data.sort_order.is_some() {
active.sort_order = Set(req.data.sort_order);
}
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
Ok(item_to_resp(m))
}
pub async fn delete_care_plan_item(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
item_id: Uuid,
operator_id: Option<Uuid>,
version: i32,
) -> HealthResult<()> {
let _plan = find_plan(state, tenant_id, plan_id).await?;
let existing = care_plan_item::Entity::find_by_id(item_id)
.one(&state.db)
.await?
.ok_or(HealthError::CarePlanItemNotFound)?;
if existing.tenant_id != tenant_id || existing.plan_id != plan_id {
return Err(HealthError::CarePlanItemNotFound);
}
let next_ver = check_version(version, existing.version)
.map_err(|_| HealthError::VersionMismatch)?;
let now = Utc::now();
let mut active: care_plan_item::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(())
}
// ---------------------------------------------------------------------------
// CarePlanOutcome CRUD
// ---------------------------------------------------------------------------
pub async fn list_care_plan_outcomes(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
page: u64,
page_size: u64,
) -> HealthResult<PaginatedResponse<CarePlanOutcomeResp>> {
let _plan = find_plan(state, tenant_id, plan_id).await?;
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let query = care_plan_outcome::Entity::find()
.filter(care_plan_outcome::Column::TenantId.eq(tenant_id))
.filter(care_plan_outcome::Column::PlanId.eq(plan_id))
.filter(care_plan_outcome::Column::DeletedAt.is_null());
let total: u64 = query.clone().count(&state.db).await?;
let rows: Vec<care_plan_outcome::Model> = query
.order_by_desc(care_plan_outcome::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(outcome_to_resp).collect();
Ok(PaginatedResponse {
data,
total,
page,
page_size,
total_pages,
})
}
pub async fn create_care_plan_outcome(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
operator_id: Option<Uuid>,
req: CreateCarePlanOutcomeReq,
) -> HealthResult<CarePlanOutcomeResp> {
let _plan = find_plan(state, tenant_id, plan_id).await?;
let now = Utc::now();
let active = care_plan_outcome::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
plan_id: Set(plan_id),
item_id: Set(req.item_id),
metric: Set(req.metric),
baseline_value: Set(req.baseline_value),
target_value: Set(req.target_value),
current_value: Set(req.current_value),
measured_at: Set(req.measured_at),
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(outcome_to_resp(m))
}
pub async fn update_care_plan_outcome(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
outcome_id: Uuid,
operator_id: Option<Uuid>,
req: UpdateCarePlanOutcomeWithVersion,
) -> HealthResult<CarePlanOutcomeResp> {
let _plan = find_plan(state, tenant_id, plan_id).await?;
let existing = care_plan_outcome::Entity::find_by_id(outcome_id)
.one(&state.db)
.await?
.ok_or(HealthError::CarePlanOutcomeNotFound)?;
if existing.tenant_id != tenant_id || existing.plan_id != plan_id {
return Err(HealthError::CarePlanOutcomeNotFound);
}
let next_ver = check_version(req.version, existing.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: care_plan_outcome::ActiveModel = existing.into();
let now = Utc::now();
if req.data.current_value.is_some() {
active.current_value = Set(req.data.current_value);
active.measured_at = Set(Some(now));
}
if let Some(v) = req.data.target_value {
active.target_value = Set(v);
}
if req.data.measured_at.is_some() {
active.measured_at = Set(req.data.measured_at);
}
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(outcome_to_resp(m))
}
pub async fn delete_care_plan_outcome(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
outcome_id: Uuid,
operator_id: Option<Uuid>,
version: i32,
) -> HealthResult<()> {
let _plan = find_plan(state, tenant_id, plan_id).await?;
let existing = care_plan_outcome::Entity::find_by_id(outcome_id)
.one(&state.db)
.await?
.ok_or(HealthError::CarePlanOutcomeNotFound)?;
if existing.tenant_id != tenant_id || existing.plan_id != plan_id {
return Err(HealthError::CarePlanOutcomeNotFound);
}
let next_ver = check_version(version, existing.version)
.map_err(|_| HealthError::VersionMismatch)?;
let now = Utc::now();
let mut active: care_plan_outcome::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(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async fn find_plan(
state: &HealthState,
tenant_id: Uuid,
plan_id: Uuid,
) -> HealthResult<care_plan::Model> {
care_plan::Entity::find_by_id(plan_id)
.one(&state.db)
.await?
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
.ok_or(HealthError::CarePlanNotFound)
}
fn plan_to_resp(m: care_plan::Model) -> CarePlanResp {
CarePlanResp {
id: m.id,
patient_id: m.patient_id,
plan_type: m.plan_type,
status: m.status,
title: m.title,
goals: m.goals,
start_date: m.start_date,
end_date: m.end_date,
notes: m.notes,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
fn item_to_resp(m: care_plan_item::Model) -> CarePlanItemResp {
CarePlanItemResp {
id: m.id,
plan_id: m.plan_id,
item_type: m.item_type,
title: m.title,
description: m.description,
status: m.status,
schedule: m.schedule,
sort_order: m.sort_order,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
fn outcome_to_resp(m: care_plan_outcome::Model) -> CarePlanOutcomeResp {
CarePlanOutcomeResp {
id: m.id,
plan_id: m.plan_id,
item_id: m.item_id,
metric: m.metric,
baseline_value: m.baseline_value,
target_value: m.target_value,
current_value: m.current_value,
measured_at: m.measured_at,
notes: m.notes,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}
}
fn validate_plan_type(plan_type: &str) -> HealthResult<()> {
let valid = ["dialysis", "chronic", "preventive", "rehabilitation"];
if valid.contains(&plan_type) {
Ok(())
} else {
Err(HealthError::Validation(format!(
"plan_type 必须为以下之一: {}",
valid.join(", ")
)))
}
}
fn validate_plan_status(status: &str) -> HealthResult<()> {
let valid = ["draft", "active", "paused", "completed", "cancelled"];
if valid.contains(&status) {
Ok(())
} else {
Err(HealthError::Validation(format!(
"status 必须为以下之一: {}",
valid.join(", ")
)))
}
}
fn validate_item_type(item_type: &str) -> HealthResult<()> {
let valid = ["intervention", "monitoring", "goal", "education"];
if valid.contains(&item_type) {
Ok(())
} else {
Err(HealthError::Validation(format!(
"item_type 必须为以下之一: {}",
valid.join(", ")
)))
}
}