- event.rs 重写为有状态处理器(订阅 workflow.task.completed / message.sent) - module.rs on_startup 初始化 HealthCrypto 并注册事件处理器 - consultation_service 消息发送改为事务包裹(INSERT + CAS 原子更新) - appointment_service 取消预约释放排班名额增加下限保护 - appointment_service update_schedule 增加 max_appointments >= current_appointments 校验 - follow_up_service 新增 complete_task_by_system 和 check_overdue_tasks - validation.rs 随访状态机增加 overdue 状态支持 - main.rs 启动时运行逾期随访检查后台任务
479 lines
18 KiB
Rust
479 lines
18 KiB
Rust
//! 预约排班 Service — 预约CRUD、排班管理、日历视图、原子CAS预约
|
||
|
||
use chrono::Utc;
|
||
use erp_core::audit::AuditLog;
|
||
use erp_core::audit_service;
|
||
use erp_core::events::DomainEvent;
|
||
use sea_orm::entity::prelude::*;
|
||
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect, TransactionTrait};
|
||
use uuid::Uuid;
|
||
|
||
use erp_core::error::check_version;
|
||
use erp_core::types::PaginatedResponse;
|
||
|
||
use crate::dto::appointment_dto::*;
|
||
use crate::entity::{appointment, doctor_profile, doctor_schedule, patient};
|
||
use crate::error::{HealthError, HealthResult};
|
||
use crate::service::validation::{
|
||
validate_appointment_status_transition, validate_appointment_type,
|
||
validate_period_type, validate_schedule_status,
|
||
};
|
||
use crate::state::HealthState;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 预约管理
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub async fn list_appointments(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
page: u64,
|
||
page_size: u64,
|
||
status: Option<String>,
|
||
patient_id: Option<Uuid>,
|
||
doctor_id: Option<Uuid>,
|
||
date: Option<chrono::NaiveDate>,
|
||
) -> HealthResult<PaginatedResponse<AppointmentResp>> {
|
||
let limit = page_size.min(100);
|
||
let offset = page.saturating_sub(1) * limit;
|
||
|
||
let mut query = appointment::Entity::find()
|
||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||
.filter(appointment::Column::DeletedAt.is_null());
|
||
|
||
if let Some(ref s) = status { query = query.filter(appointment::Column::Status.eq(s)); }
|
||
if let Some(pid) = patient_id { query = query.filter(appointment::Column::PatientId.eq(pid)); }
|
||
if let Some(did) = doctor_id { query = query.filter(appointment::Column::DoctorId.eq(did)); }
|
||
if let Some(d) = date { query = query.filter(appointment::Column::AppointmentDate.eq(d)); }
|
||
|
||
let total = query.clone().count(&state.db).await?;
|
||
let models = query
|
||
.order_by_desc(appointment::Column::AppointmentDate)
|
||
.offset(offset)
|
||
.limit(limit)
|
||
.all(&state.db)
|
||
.await?;
|
||
|
||
let total_pages = total.div_ceil(limit.max(1));
|
||
let data = models.into_iter().map(|m| AppointmentResp {
|
||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
||
appointment_type: m.appointment_type, appointment_date: m.appointment_date,
|
||
start_time: m.start_time, end_time: m.end_time,
|
||
status: m.status, cancel_reason: m.cancel_reason, notes: m.notes,
|
||
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||
}).collect();
|
||
|
||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||
}
|
||
|
||
pub async fn get_appointment(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
appointment_id: Uuid,
|
||
) -> HealthResult<AppointmentResp> {
|
||
let m = appointment::Entity::find()
|
||
.filter(appointment::Column::Id.eq(appointment_id))
|
||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||
.filter(appointment::Column::DeletedAt.is_null())
|
||
.one(&state.db)
|
||
.await?
|
||
.ok_or(HealthError::AppointmentNotFound)?;
|
||
|
||
Ok(AppointmentResp {
|
||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
||
appointment_type: m.appointment_type, appointment_date: m.appointment_date,
|
||
start_time: m.start_time, end_time: m.end_time,
|
||
status: m.status, cancel_reason: m.cancel_reason, notes: m.notes,
|
||
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||
})
|
||
}
|
||
|
||
pub async fn create_appointment(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
operator_id: Option<Uuid>,
|
||
req: CreateAppointmentReq,
|
||
) -> HealthResult<AppointmentResp> {
|
||
// 校验患者存在
|
||
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)?;
|
||
|
||
if let Some(ref at) = req.appointment_type { validate_appointment_type(at)?; }
|
||
|
||
let doctor_id_val = req.doctor_id.ok_or(HealthError::Validation("doctor_id is required".to_string()))?;
|
||
|
||
// 校验医护存在
|
||
doctor_profile::Entity::find()
|
||
.filter(doctor_profile::Column::Id.eq(doctor_id_val))
|
||
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
|
||
.filter(doctor_profile::Column::DeletedAt.is_null())
|
||
.one(&state.db)
|
||
.await?
|
||
.ok_or(HealthError::DoctorNotFound)?;
|
||
|
||
// 事务包裹 CAS + INSERT,防止 CAS 成功但 INSERT 失败产生幽灵占位
|
||
let txn = state.db.begin().await?;
|
||
|
||
// 原子 CAS: 排班名额 +1
|
||
let cas_result = doctor_schedule::Entity::update_many()
|
||
.col_expr(
|
||
doctor_schedule::Column::CurrentAppointments,
|
||
Expr::col(doctor_schedule::Column::CurrentAppointments).add(1),
|
||
)
|
||
.col_expr(doctor_schedule::Column::UpdatedAt, Expr::value(Utc::now()))
|
||
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
|
||
.filter(doctor_schedule::Column::DoctorId.eq(doctor_id_val))
|
||
.filter(doctor_schedule::Column::ScheduleDate.eq(req.appointment_date))
|
||
.filter(doctor_schedule::Column::StartTime.eq(req.start_time))
|
||
.filter(
|
||
Condition::all()
|
||
.add(doctor_schedule::Column::DeletedAt.is_null())
|
||
.add(
|
||
Expr::col(doctor_schedule::Column::CurrentAppointments)
|
||
.lt(Expr::col(doctor_schedule::Column::MaxAppointments))
|
||
)
|
||
)
|
||
.exec(&txn)
|
||
.await?;
|
||
|
||
if cas_result.rows_affected == 0 {
|
||
txn.rollback().await?;
|
||
return Err(HealthError::ScheduleFull);
|
||
}
|
||
|
||
let now = Utc::now();
|
||
let active = appointment::ActiveModel {
|
||
id: Set(Uuid::now_v7()),
|
||
tenant_id: Set(tenant_id),
|
||
patient_id: Set(req.patient_id),
|
||
doctor_id: Set(Some(doctor_id_val)),
|
||
appointment_type: Set(req.appointment_type.unwrap_or_else(|| "outpatient".to_string())),
|
||
appointment_date: Set(req.appointment_date),
|
||
start_time: Set(req.start_time),
|
||
end_time: Set(req.end_time),
|
||
status: Set("pending".to_string()),
|
||
cancel_reason: Set(None),
|
||
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(&txn).await?;
|
||
|
||
txn.commit().await?;
|
||
|
||
let event = DomainEvent::new(
|
||
"appointment.created",
|
||
tenant_id,
|
||
serde_json::json!({ "appointment_id": m.id, "patient_id": m.patient_id, "status": m.status }),
|
||
);
|
||
state.event_bus.publish(event, &state.db).await;
|
||
|
||
audit_service::record(
|
||
AuditLog::new(tenant_id, operator_id, "appointment.created", "appointment")
|
||
.with_resource_id(m.id),
|
||
&state.db,
|
||
).await;
|
||
|
||
Ok(AppointmentResp {
|
||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
||
appointment_type: m.appointment_type, appointment_date: m.appointment_date,
|
||
start_time: m.start_time, end_time: m.end_time,
|
||
status: m.status, cancel_reason: m.cancel_reason, notes: m.notes,
|
||
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||
})
|
||
}
|
||
|
||
pub async fn update_appointment_status(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
appointment_id: Uuid,
|
||
operator_id: Option<Uuid>,
|
||
req: UpdateAppointmentStatusReq,
|
||
expected_version: i32,
|
||
) -> HealthResult<AppointmentResp> {
|
||
let model = appointment::Entity::find()
|
||
.filter(appointment::Column::Id.eq(appointment_id))
|
||
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||
.filter(appointment::Column::DeletedAt.is_null())
|
||
.one(&state.db)
|
||
.await?
|
||
.ok_or(HealthError::AppointmentNotFound)?;
|
||
|
||
// 状态机校验
|
||
validate_appointment_status_transition(&model.status, &req.status)?;
|
||
|
||
let next_ver = check_version(expected_version, model.version)
|
||
.map_err(|_| HealthError::VersionMismatch)?;
|
||
|
||
let old_status = model.status.clone();
|
||
|
||
let txn = state.db.begin().await?;
|
||
|
||
// 取消时释放排班名额(带下限保护)
|
||
if req.status == "cancelled" {
|
||
if let Some(did) = model.doctor_id {
|
||
let release_result = doctor_schedule::Entity::update_many()
|
||
.col_expr(
|
||
doctor_schedule::Column::CurrentAppointments,
|
||
Expr::col(doctor_schedule::Column::CurrentAppointments).sub(1),
|
||
)
|
||
.col_expr(doctor_schedule::Column::UpdatedAt, Expr::value(Utc::now()))
|
||
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
|
||
.filter(doctor_schedule::Column::DoctorId.eq(did))
|
||
.filter(doctor_schedule::Column::ScheduleDate.eq(model.appointment_date))
|
||
.filter(doctor_schedule::Column::DeletedAt.is_null())
|
||
.filter(Expr::col(doctor_schedule::Column::CurrentAppointments).gt(0))
|
||
.exec(&txn)
|
||
.await
|
||
.map_err(|e| HealthError::DbError(format!("取消预约时释放排班名额失败: {}", e)))?;
|
||
if release_result.rows_affected == 0 {
|
||
tracing::warn!(
|
||
doctor_id = %did,
|
||
date = %model.appointment_date,
|
||
"取消预约时未找到匹配排班记录,可能已被删除"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut active: appointment::ActiveModel = model.into();
|
||
active.status = Set(req.status);
|
||
active.cancel_reason = Set(req.cancel_reason);
|
||
active.updated_at = Set(Utc::now());
|
||
active.updated_by = Set(operator_id);
|
||
active.version = Set(next_ver);
|
||
|
||
let m = active.update(&txn).await?;
|
||
|
||
txn.commit().await?;
|
||
|
||
let event_type = format!("appointment.{}", m.status);
|
||
let event = DomainEvent::new(
|
||
event_type,
|
||
tenant_id,
|
||
serde_json::json!({ "appointment_id": m.id, "patient_id": m.patient_id, "status": m.status }),
|
||
);
|
||
state.event_bus.publish(event, &state.db).await;
|
||
|
||
audit_service::record(
|
||
AuditLog::new(tenant_id, operator_id, "appointment.status_changed", "appointment")
|
||
.with_resource_id(m.id)
|
||
.with_changes(
|
||
Some(serde_json::json!({ "status": old_status })),
|
||
Some(serde_json::json!({ "status": m.status })),
|
||
),
|
||
&state.db,
|
||
).await;
|
||
|
||
Ok(AppointmentResp {
|
||
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
|
||
appointment_type: m.appointment_type, appointment_date: m.appointment_date,
|
||
start_time: m.start_time, end_time: m.end_time,
|
||
status: m.status, cancel_reason: m.cancel_reason, notes: m.notes,
|
||
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 排班管理
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub async fn list_schedules(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
page: u64,
|
||
page_size: u64,
|
||
doctor_id: Option<Uuid>,
|
||
date: Option<chrono::NaiveDate>,
|
||
) -> HealthResult<PaginatedResponse<ScheduleResp>> {
|
||
let limit = page_size.min(100);
|
||
let offset = page.saturating_sub(1) * limit;
|
||
|
||
let mut query = doctor_schedule::Entity::find()
|
||
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
|
||
.filter(doctor_schedule::Column::DeletedAt.is_null());
|
||
|
||
if let Some(did) = doctor_id { query = query.filter(doctor_schedule::Column::DoctorId.eq(did)); }
|
||
if let Some(d) = date { query = query.filter(doctor_schedule::Column::ScheduleDate.eq(d)); }
|
||
|
||
let total = query.clone().count(&state.db).await?;
|
||
let models = query
|
||
.order_by_asc(doctor_schedule::Column::ScheduleDate)
|
||
.offset(offset)
|
||
.limit(limit)
|
||
.all(&state.db)
|
||
.await?;
|
||
|
||
let total_pages = total.div_ceil(limit.max(1));
|
||
let data = models.into_iter().map(|m| ScheduleResp {
|
||
id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date,
|
||
period_type: m.period_type, start_time: m.start_time, end_time: m.end_time,
|
||
max_appointments: m.max_appointments, current_appointments: m.current_appointments,
|
||
status: m.status, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||
}).collect();
|
||
|
||
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
|
||
}
|
||
|
||
pub async fn create_schedule(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
operator_id: Option<Uuid>,
|
||
req: CreateScheduleReq,
|
||
) -> HealthResult<ScheduleResp> {
|
||
let now = Utc::now();
|
||
let period_type = req.period_type.unwrap_or_else(|| "am".to_string());
|
||
validate_period_type(&period_type)?;
|
||
|
||
// H-6: 校验医生存在
|
||
doctor_profile::Entity::find()
|
||
.filter(doctor_profile::Column::Id.eq(req.doctor_id))
|
||
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
|
||
.filter(doctor_profile::Column::DeletedAt.is_null())
|
||
.one(&state.db)
|
||
.await?
|
||
.ok_or(HealthError::DoctorNotFound)?;
|
||
|
||
let active = doctor_schedule::ActiveModel {
|
||
id: Set(Uuid::now_v7()),
|
||
tenant_id: Set(tenant_id),
|
||
doctor_id: Set(req.doctor_id),
|
||
schedule_date: Set(req.schedule_date),
|
||
period_type: Set(period_type),
|
||
start_time: Set(req.start_time),
|
||
end_time: Set(req.end_time),
|
||
max_appointments: Set(req.max_appointments),
|
||
current_appointments: Set(0),
|
||
status: Set("enabled".to_string()),
|
||
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(ScheduleResp {
|
||
id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date,
|
||
period_type: m.period_type, start_time: m.start_time, end_time: m.end_time,
|
||
max_appointments: m.max_appointments, current_appointments: m.current_appointments,
|
||
status: m.status, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||
})
|
||
}
|
||
|
||
pub async fn update_schedule(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
schedule_id: Uuid,
|
||
operator_id: Option<Uuid>,
|
||
req: UpdateScheduleReq,
|
||
expected_version: i32,
|
||
) -> HealthResult<ScheduleResp> {
|
||
let model = doctor_schedule::Entity::find()
|
||
.filter(doctor_schedule::Column::Id.eq(schedule_id))
|
||
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
|
||
.filter(doctor_schedule::Column::DeletedAt.is_null())
|
||
.one(&state.db)
|
||
.await?
|
||
.ok_or(HealthError::ScheduleNotFound)?;
|
||
|
||
let next_ver = check_version(expected_version, model.version)
|
||
.map_err(|_| HealthError::VersionMismatch)?;
|
||
|
||
if let Some(ref s) = req.status { validate_schedule_status(s)?; }
|
||
|
||
// 不允许将 max_appointments 设为小于当前已预约数
|
||
if let Some(new_max) = req.max_appointments {
|
||
if new_max < model.current_appointments {
|
||
return Err(HealthError::Validation(
|
||
format!("max_appointments ({}) 不能小于当前已预约数 ({})", new_max, model.current_appointments)
|
||
));
|
||
}
|
||
}
|
||
|
||
let mut active: doctor_schedule::ActiveModel = model.into();
|
||
if let Some(v) = req.start_time { active.start_time = Set(v); }
|
||
if let Some(v) = req.end_time { active.end_time = Set(v); }
|
||
if let Some(v) = req.max_appointments { active.max_appointments = Set(v); }
|
||
if let Some(v) = req.status { active.status = Set(v); }
|
||
active.updated_at = Set(Utc::now());
|
||
active.updated_by = Set(operator_id);
|
||
active.version = Set(next_ver);
|
||
|
||
let m = active.update(&state.db).await?;
|
||
Ok(ScheduleResp {
|
||
id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date,
|
||
period_type: m.period_type, start_time: m.start_time, end_time: m.end_time,
|
||
max_appointments: m.max_appointments, current_appointments: m.current_appointments,
|
||
status: m.status, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 日历视图
|
||
// ---------------------------------------------------------------------------
|
||
|
||
pub async fn calendar_view(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
start_date: chrono::NaiveDate,
|
||
end_date: chrono::NaiveDate,
|
||
doctor_id: Option<Uuid>,
|
||
) -> HealthResult<Vec<CalendarDayResp>> {
|
||
// H-3: 限制日期范围跨度最多 90 天
|
||
let max_span = chrono::Duration::days(90);
|
||
if end_date - start_date > max_span {
|
||
return Err(HealthError::Validation("日历查询范围不能超过 90 天".to_string()));
|
||
}
|
||
|
||
let mut query = doctor_schedule::Entity::find()
|
||
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
|
||
.filter(doctor_schedule::Column::DeletedAt.is_null())
|
||
.filter(doctor_schedule::Column::ScheduleDate.gte(start_date))
|
||
.filter(doctor_schedule::Column::ScheduleDate.lte(end_date));
|
||
|
||
if let Some(did) = doctor_id {
|
||
query = query.filter(doctor_schedule::Column::DoctorId.eq(did));
|
||
}
|
||
|
||
let schedules = query
|
||
.order_by_asc(doctor_schedule::Column::ScheduleDate)
|
||
.all(&state.db)
|
||
.await?;
|
||
|
||
// 按日期分组
|
||
use std::collections::BTreeMap;
|
||
let mut map: BTreeMap<chrono::NaiveDate, Vec<ScheduleResp>> = BTreeMap::new();
|
||
for m in schedules {
|
||
let resp = ScheduleResp {
|
||
id: m.id, doctor_id: m.doctor_id, schedule_date: m.schedule_date,
|
||
period_type: m.period_type, start_time: m.start_time, end_time: m.end_time,
|
||
max_appointments: m.max_appointments, current_appointments: m.current_appointments,
|
||
status: m.status, created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||
};
|
||
map.entry(m.schedule_date).or_default().push(resp);
|
||
}
|
||
|
||
// 填充日期范围内的所有日期
|
||
let mut result = Vec::new();
|
||
let mut d = start_date;
|
||
while d <= end_date {
|
||
result.push(CalendarDayResp {
|
||
date: d,
|
||
schedules: map.remove(&d).unwrap_or_default(),
|
||
});
|
||
d = d.succ_opt().unwrap_or(d);
|
||
}
|
||
|
||
Ok(result)
|
||
}
|