feat(health): 批量随访操作 — batch_create/assign/complete 三个端点
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

POST /health/follow-up-tasks/batch-create — 多患者同配置批量创建
POST /health/follow-up-tasks/batch-assign — 批量分配负责人
POST /health/follow-up-tasks/batch-complete — 批量标记完成

含参数校验(上限 100)、部分失败报告、事件发布、审计日志。
This commit is contained in:
iven
2026-04-27 14:01:58 +08:00
parent a36720cbbc
commit 19cb2bf8bf
4 changed files with 338 additions and 0 deletions

View File

@@ -88,6 +88,44 @@ impl CreateFollowUpRecordReq {
}
}
// ---------------------------------------------------------------------------
// 批量操作
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchCreateTasksReq {
pub patient_ids: Vec<Uuid>,
pub assigned_to: Option<Uuid>,
pub follow_up_type: String,
pub planned_date: NaiveDate,
pub content_template: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchAssignReq {
pub task_ids: Vec<Uuid>,
pub assigned_to: Uuid,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchCompleteReq {
pub task_ids: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchResultResp {
pub succeeded: u32,
pub failed: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<BatchError>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchError {
pub index: usize,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FollowUpRecordResp {
pub id: Uuid,

View File

@@ -13,6 +13,79 @@ use crate::dto::DeleteWithVersion;
use crate::service::follow_up_service;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
// 批量操作 Handler
// ---------------------------------------------------------------------------
pub async fn batch_create_tasks<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BatchCreateTasksReq>,
) -> Result<Json<ApiResponse<BatchResultResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
if req.patient_ids.is_empty() {
return Err(AppError::Validation("patient_ids 不能为空".to_string()));
}
if req.patient_ids.len() > 100 {
return Err(AppError::Validation("单次批量最多 100 条".to_string()));
}
let result = follow_up_service::batch_create_tasks(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn batch_assign_tasks<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BatchAssignReq>,
) -> Result<Json<ApiResponse<BatchResultResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
if req.task_ids.is_empty() {
return Err(AppError::Validation("task_ids 不能为空".to_string()));
}
if req.task_ids.len() > 100 {
return Err(AppError::Validation("单次批量最多 100 条".to_string()));
}
let result = follow_up_service::batch_assign_tasks(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn batch_complete_tasks<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BatchCompleteReq>,
) -> Result<Json<ApiResponse<BatchResultResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
if req.task_ids.is_empty() {
return Err(AppError::Validation("task_ids 不能为空".to_string()));
}
if req.task_ids.len() > 100 {
return Err(AppError::Validation("单次批量最多 100 条".to_string()));
}
let result = follow_up_service::batch_complete_tasks(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct FollowUpTaskListParams {
pub page: Option<u64>,

View File

@@ -289,6 +289,19 @@ impl HealthModule {
"/health/follow-up-records",
axum::routing::get(follow_up_handler::list_records),
)
// 随访批量操作
.route(
"/health/follow-up-tasks/batch-create",
axum::routing::post(follow_up_handler::batch_create_tasks),
)
.route(
"/health/follow-up-tasks/batch-assign",
axum::routing::post(follow_up_handler::batch_assign_tasks),
)
.route(
"/health/follow-up-tasks/batch-complete",
axum::routing::post(follow_up_handler::batch_complete_tasks),
)
// 咨询管理
.route(
"/health/consultation-sessions",

View File

@@ -292,6 +292,220 @@ pub async fn delete_task(
Ok(())
}
// ---------------------------------------------------------------------------
// 批量操作
// ---------------------------------------------------------------------------
pub async fn batch_create_tasks(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: BatchCreateTasksReq,
) -> HealthResult<BatchResultResp> {
validate_follow_up_type(&req.follow_up_type)?;
let mut succeeded: u32 = 0;
let mut errors: Vec<crate::dto::follow_up_dto::BatchError> = Vec::new();
// 批量校验患者存在
let valid_ids: HashSet<Uuid> = patient::Entity::find()
.filter(patient::Column::Id.is_in(req.patient_ids.clone()))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.all(&state.db)
.await?
.into_iter()
.map(|p| p.id)
.collect();
let now = Utc::now();
let mut models_to_insert = Vec::new();
for (i, pid) in req.patient_ids.iter().enumerate() {
if !valid_ids.contains(pid) {
errors.push(crate::dto::follow_up_dto::BatchError {
index: i,
message: format!("患者 {} 不存在", pid),
});
continue;
}
models_to_insert.push(follow_up_task::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(*pid),
assigned_to: Set(req.assigned_to),
follow_up_type: Set(req.follow_up_type.clone()),
planned_date: Set(req.planned_date),
status: Set("pending".to_string()),
content_template: Set(req.content_template.clone()),
related_appointment_id: 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),
});
}
if !models_to_insert.is_empty() {
let count = models_to_insert.len() as u32;
follow_up_task::Entity::insert_many(models_to_insert)
.exec(&state.db)
.await
.map_err(|e| HealthError::DbError(e.to_string()))?;
// 发布聚合事件(而非逐条发布)
state.event_bus.publish(
DomainEvent::new(
crate::event::FOLLOW_UP_CREATED,
tenant_id,
serde_json::json!({ "batch_count": count, "assigned_to": req.assigned_to }),
),
&state.db,
).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "follow_up_task.batch_created", "follow_up_task")
.with_resource_id(req.patient_ids[0]),
&state.db,
).await;
succeeded = count;
}
Ok(BatchResultResp {
succeeded,
failed: errors.len() as u32,
errors,
})
}
pub async fn batch_assign_tasks(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: BatchAssignReq,
) -> HealthResult<BatchResultResp> {
let mut succeeded: u32 = 0;
let mut errors: Vec<crate::dto::follow_up_dto::BatchError> = Vec::new();
let tasks: Vec<follow_up_task::Model> = follow_up_task::Entity::find()
.filter(follow_up_task::Column::Id.is_in(req.task_ids.clone()))
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
let found_ids: HashSet<Uuid> = tasks.iter().map(|t| t.id).collect();
for (i, tid) in req.task_ids.iter().enumerate() {
if !found_ids.contains(tid) {
errors.push(crate::dto::follow_up_dto::BatchError {
index: i,
message: format!("任务 {} 不存在", tid),
});
}
}
if found_ids.is_empty() {
return Ok(BatchResultResp { succeeded: 0, failed: errors.len() as u32, errors });
}
// 批量更新 assigned_to
let now = Utc::now();
for task in tasks {
let mut active: follow_up_task::ActiveModel = task.into();
active.assigned_to = Set(Some(req.assigned_to));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.update(&state.db).await.map_err(|e| HealthError::DbError(e.to_string()))?;
succeeded += 1;
}
audit_service::record(
AuditLog::new(tenant_id, operator_id, "follow_up_task.batch_assigned", "follow_up_task")
.with_resource_id(req.assigned_to),
&state.db,
).await;
Ok(BatchResultResp {
succeeded,
failed: errors.len() as u32,
errors,
})
}
pub async fn batch_complete_tasks(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: BatchCompleteReq,
) -> HealthResult<BatchResultResp> {
let mut succeeded: u32 = 0;
let mut errors: Vec<crate::dto::follow_up_dto::BatchError> = Vec::new();
let task_id_refs: Vec<Uuid> = req.task_ids.clone();
let tasks: Vec<follow_up_task::Model> = follow_up_task::Entity::find()
.filter(follow_up_task::Column::Id.is_in(task_id_refs))
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
let found_map: HashMap<Uuid, follow_up_task::Model> =
tasks.into_iter().map(|t| (t.id, t)).collect();
let now = Utc::now();
for (i, tid) in req.task_ids.iter().enumerate() {
if let Some(task) = found_map.get(tid) {
if task.status != "pending" && task.status != "in_progress" {
errors.push(crate::dto::follow_up_dto::BatchError {
index: i,
message: format!("任务状态为 {},无法完成", task.status),
});
continue;
}
let next_ver = task.version + 1;
let mut active: follow_up_task::ActiveModel = task.clone().into();
active.status = Set("completed".to_string());
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
active.update(&state.db).await.map_err(|e| HealthError::DbError(e.to_string()))?;
succeeded += 1;
} else {
errors.push(crate::dto::follow_up_dto::BatchError {
index: i,
message: format!("任务 {} 不存在", tid),
});
}
}
if succeeded > 0 {
state.event_bus.publish(
DomainEvent::new(
crate::event::FOLLOW_UP_COMPLETED,
tenant_id,
serde_json::json!({ "batch_count": succeeded }),
),
&state.db,
).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "follow_up_task.batch_completed", "follow_up_task")
.with_resource_id(req.task_ids[0]),
&state.db,
).await;
}
Ok(BatchResultResp {
succeeded,
failed: errors.len() as u32,
errors,
})
}
// ---------------------------------------------------------------------------
// 随访记录
// ---------------------------------------------------------------------------