From 19cb2bf8bff557da433acf64676a676a4a24350a Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 27 Apr 2026 14:01:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E6=89=B9=E9=87=8F=E9=9A=8F?= =?UTF-8?q?=E8=AE=BF=E6=93=8D=E4=BD=9C=20=E2=80=94=20batch=5Fcreate/assign?= =?UTF-8?q?/complete=20=E4=B8=89=E4=B8=AA=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /health/follow-up-tasks/batch-create — 多患者同配置批量创建 POST /health/follow-up-tasks/batch-assign — 批量分配负责人 POST /health/follow-up-tasks/batch-complete — 批量标记完成 含参数校验(上限 100)、部分失败报告、事件发布、审计日志。 --- crates/erp-health/src/dto/follow_up_dto.rs | 38 ++++ .../src/handler/follow_up_handler.rs | 73 ++++++ crates/erp-health/src/module.rs | 13 ++ .../src/service/follow_up_service.rs | 214 ++++++++++++++++++ 4 files changed, 338 insertions(+) diff --git a/crates/erp-health/src/dto/follow_up_dto.rs b/crates/erp-health/src/dto/follow_up_dto.rs index 94ea328..bbc3173 100644 --- a/crates/erp-health/src/dto/follow_up_dto.rs +++ b/crates/erp-health/src/dto/follow_up_dto.rs @@ -88,6 +88,44 @@ impl CreateFollowUpRecordReq { } } +// --------------------------------------------------------------------------- +// 批量操作 +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct BatchCreateTasksReq { + pub patient_ids: Vec, + pub assigned_to: Option, + pub follow_up_type: String, + pub planned_date: NaiveDate, + pub content_template: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct BatchAssignReq { + pub task_ids: Vec, + pub assigned_to: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct BatchCompleteReq { + pub task_ids: Vec, +} + +#[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, +} + +#[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, diff --git a/crates/erp-health/src/handler/follow_up_handler.rs b/crates/erp-health/src/handler/follow_up_handler.rs index b16dcd6..36965f9 100644 --- a/crates/erp-health/src/handler/follow_up_handler.rs +++ b/crates/erp-health/src/handler/follow_up_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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, diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index 18139d6..6a45799 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -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", diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs index 8fbc7e6..3a6d500 100644 --- a/crates/erp-health/src/service/follow_up_service.rs +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -292,6 +292,220 @@ pub async fn delete_task( Ok(()) } +// --------------------------------------------------------------------------- +// 批量操作 +// --------------------------------------------------------------------------- + +pub async fn batch_create_tasks( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: BatchCreateTasksReq, +) -> HealthResult { + validate_follow_up_type(&req.follow_up_type)?; + let mut succeeded: u32 = 0; + let mut errors: Vec = Vec::new(); + + // 批量校验患者存在 + let valid_ids: HashSet = 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, + req: BatchAssignReq, +) -> HealthResult { + let mut succeeded: u32 = 0; + let mut errors: Vec = Vec::new(); + + let tasks: Vec = 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 = 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, + req: BatchCompleteReq, +) -> HealthResult { + let mut succeeded: u32 = 0; + let mut errors: Vec = Vec::new(); + + let task_id_refs: Vec = req.task_ids.clone(); + let tasks: Vec = 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 = + 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, + }) +} + // --------------------------------------------------------------------------- // 随访记录 // ---------------------------------------------------------------------------