Files
hms/crates/erp-health/src/service/appointment_service.rs
iven 43e127d4f7 feat(health): 事件驱动集成 + 数据一致性修复 + 逾期随访检查
- 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 启动时运行逾期随访检查后台任务
2026-04-25 00:30:32 +08:00

479 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 预约排班 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)
}