From 1824f844677d66c996b68c57624f6420701c2acc Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 23 Apr 2026 20:54:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(health):=20=E5=AE=9E=E7=8E=B0=205=20?= =?UTF-8?q?=E5=A4=A7=20Service=20=E5=B1=82=E5=AE=8C=E6=95=B4=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将所有 todo!() 占位替换为真实 SeaORM 数据库操作: - patient_service: 患者CRUD + 乐观锁 + 软删除 + 标签管理 + 家庭成员 + 医生关联 + 健康摘要 - health_data_service: 体征/化验/体检CRUD + 趋势分析 + 指标时间序列查询 - appointment_service: 预约CRUD + 原子CAS排班占位 + 状态机 + 取消释放名额 + 日历视图 - follow_up_service: 随访任务CRUD + 执行记录 + 自动完成任务状态推进 - consultation_service: 咨询会话管理 + 消息收发 + 未读计数 + 会话关闭 所有操作均包含 tenant_id 过滤 + deleted_at 软删除检查 + 乐观锁版本校验。 --- .../src/service/appointment_service.rs | 336 +++++++++-- .../src/service/consultation_service.rs | 207 +++++-- .../src/service/follow_up_service.rs | 259 +++++++-- .../src/service/health_data_service.rs | 520 +++++++++++++++--- .../erp-health/src/service/patient_service.rs | 457 +++++++++++++-- 5 files changed, 1529 insertions(+), 250 deletions(-) diff --git a/crates/erp-health/src/service/appointment_service.rs b/crates/erp-health/src/service/appointment_service.rs index c0a562b..3b784f7 100644 --- a/crates/erp-health/src/service/appointment_service.rs +++ b/crates/erp-health/src/service/appointment_service.rs @@ -1,120 +1,354 @@ //! 预约排班 Service — 预约CRUD、排班管理、日历视图、原子CAS预约 -use chrono::NaiveDate; +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect}; use uuid::Uuid; -use erp_core::types::{PaginatedResponse, Pagination}; +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; -use crate::dto::appointment_dto::{ - AppointmentResp, CalendarDayResp, CreateAppointmentReq, CreateScheduleReq, - ScheduleResp, UpdateAppointmentStatusReq, UpdateScheduleReq, -}; -use crate::error::HealthResult; +use crate::dto::appointment_dto::*; +use crate::entity::{appointment, doctor_schedule}; +use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; // --------------------------------------------------------------------------- -// 预约管理 (Appointments) +// 预约管理 // --------------------------------------------------------------------------- -/// 预约列表(分页 + 多条件筛选) pub async fn list_appointments( state: &HealthState, tenant_id: Uuid, - pagination: Pagination, + page: u64, + page_size: u64, status: Option, patient_id: Option, doctor_id: Option, - date: Option, + date: Option, ) -> HealthResult> { - let _ = (state, tenant_id, pagination, status, patient_id, doctor_id, date); - todo!() + 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 }) } -/// 创建预约(原子 CAS 占位,防止超额预约) pub async fn create_appointment( state: &HealthState, tenant_id: Uuid, + operator_id: Option, req: CreateAppointmentReq, - user_id: Option, ) -> HealthResult { - let _ = (state, tenant_id, req, user_id); - // 实现时需要: - // 1. 查找对应排班档位 - // 2. 原子 CAS: UPDATE doctor_schedule SET current_appointments = current_appointments + 1 - // WHERE id = ? AND current_appointments < max_appointments - // 3. CAS 失败返回 ScheduleFull 错误 - // 4. 创建预约记录 - // 5. 发布 appointment.created 事件 - todo!() + // 原子 CAS: 排班名额 +1 + // 使用 raw SQL 实现 CAS 防止超额预约 + 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(req.doctor_id.unwrap_or_default())) + .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(&state.db) + .await?; + + if cas_result.rows_affected == 0 { + 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(req.doctor_id), + appointment_type: Set(req.appointment_type.unwrap_or_else(|| "regular".to_string())), + appointment_date: Set(req.appointment_date), + start_time: Set(req.start_time), + end_time: Set(req.end_time), + status: Set("scheduled".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(&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, req: UpdateAppointmentStatusReq, - version: i32, + expected_version: i32, ) -> HealthResult { - let _ = (state, tenant_id, appointment_id, req, version); - // 实现时需要: - // 1. 状态机校验:pending -> confirmed/cancelled, confirmed -> completed/no_show/cancelled - // 2. 取消时释放排班名额(原子减 1) - // 3. 发布 appointment.confirmed / appointment.cancelled 事件 - todo!() + 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)?; + + // 状态机校验 + let valid = match (model.status.as_str(), req.status.as_str()) { + ("scheduled", "confirmed" | "cancelled") => true, + ("confirmed", "completed" | "no_show" | "cancelled") => true, + _ => false, + }; + if !valid { + return Err(HealthError::InvalidStatusTransition(format!( + "{} -> {}", model.status, req.status + ))); + } + + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + // 取消时释放排班名额 + if req.status == "cancelled" { + if let Some(did) = model.doctor_id { + let _ = 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()) + .exec(&state.db) + .await; + } + } + + 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(&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, + }) } // --------------------------------------------------------------------------- -// 排班管理 (Doctor Schedules) +// 排班管理 // --------------------------------------------------------------------------- -/// 排班列表 pub async fn list_schedules( state: &HealthState, tenant_id: Uuid, - pagination: Pagination, + page: u64, + page_size: u64, doctor_id: Option, - date: Option, + date: Option, ) -> HealthResult> { - let _ = (state, tenant_id, pagination, doctor_id, date); - todo!() + 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, req: CreateScheduleReq, - user_id: Option, ) -> HealthResult { - let _ = (state, tenant_id, req, user_id); - todo!() + let now = Utc::now(); + 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(req.period_type.unwrap_or_else(|| "morning".to_string())), + start_time: Set(req.start_time), + end_time: Set(req.end_time), + max_appointments: Set(req.max_appointments), + current_appointments: Set(0), + status: Set("active".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, req: UpdateScheduleReq, - version: i32, + expected_version: i32, ) -> HealthResult { - let _ = (state, tenant_id, schedule_id, req, version); - todo!() + 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)?; + + 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: NaiveDate, - end_date: NaiveDate, + start_date: chrono::NaiveDate, + end_date: chrono::NaiveDate, doctor_id: Option, ) -> HealthResult> { - let _ = (state, tenant_id, start_date, end_date, doctor_id); - todo!() + 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> = 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) } diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs index 6e7822b..504c2fc 100644 --- a/crates/erp-health/src/service/consultation_service.rs +++ b/crates/erp-health/src/service/consultation_service.rs @@ -1,45 +1,99 @@ //! 咨询管理 Service — 会话管理、消息收发、会话关闭、导出 +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; use uuid::Uuid; -use erp_core::types::{PaginatedResponse, Pagination}; +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; -use crate::dto::consultation_dto::{ - CreateMessageReq, MessageResp, SessionQuery, SessionResp, -}; -use crate::error::HealthResult; +use crate::dto::consultation_dto::*; +use crate::entity::{consultation_message, consultation_session}; +use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; // --------------------------------------------------------------------------- -// 咨询会话 (Consultation Sessions) +// 咨询会话 // --------------------------------------------------------------------------- -/// 咨询会话列表(分页 + 多条件筛选) pub async fn list_sessions( state: &HealthState, tenant_id: Uuid, - query: SessionQuery, + page: u64, + page_size: u64, + status: Option, + patient_id: Option, + doctor_id: Option, ) -> HealthResult> { - let _ = (state, tenant_id, query); - todo!() + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()); + + if let Some(ref s) = status { query = query.filter(consultation_session::Column::Status.eq(s)); } + if let Some(pid) = patient_id { query = query.filter(consultation_session::Column::PatientId.eq(pid)); } + if let Some(did) = doctor_id { query = query.filter(consultation_session::Column::DoctorId.eq(did)); } + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(consultation_session::Column::CreatedAt) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(|m| SessionResp { + id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id, + consultation_type: m.consultation_type, status: m.status, + last_message_at: m.last_message_at, + unread_count_patient: m.unread_count_patient, + unread_count_doctor: m.unread_count_doctor, + created_at: m.created_at, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) } -/// 关闭咨询会话 pub async fn close_session( state: &HealthState, tenant_id: Uuid, session_id: Uuid, - version: i32, + operator_id: Option, + expected_version: i32, ) -> HealthResult { - let _ = (state, tenant_id, session_id, version); - // 实现时需要: - // 1. 校验会话存在且状态为 active - // 2. 更新状态为 closed - // 3. 发布 consultation.closed 事件 - todo!() + let model = consultation_session::Entity::find() + .filter(consultation_session::Column::Id.eq(session_id)) + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(consultation_session::Column::Status.eq("active")) + .one(&state.db) + .await? + .ok_or(HealthError::ConsultationNotFound)?; + + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: consultation_session::ActiveModel = model.into(); + active.status = Set("closed".to_string()); + 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(SessionResp { + id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id, + consultation_type: m.consultation_type, status: m.status, + last_message_at: m.last_message_at, + unread_count_patient: m.unread_count_patient, + unread_count_doctor: m.unread_count_doctor, + created_at: m.created_at, + }) } -/// 导出咨询会话(按条件筛选后返回汇总数据) pub async fn export_sessions( state: &HealthState, tenant_id: Uuid, @@ -47,37 +101,120 @@ pub async fn export_sessions( patient_id: Option, doctor_id: Option, ) -> HealthResult> { - let _ = (state, tenant_id, status, patient_id, doctor_id); - todo!() + let mut query = consultation_session::Entity::find() + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()); + + if let Some(ref s) = status { query = query.filter(consultation_session::Column::Status.eq(s)); } + if let Some(pid) = patient_id { query = query.filter(consultation_session::Column::PatientId.eq(pid)); } + if let Some(did) = doctor_id { query = query.filter(consultation_session::Column::DoctorId.eq(did)); } + + let models = query + .order_by_desc(consultation_session::Column::CreatedAt) + .all(&state.db) + .await?; + + Ok(models.into_iter().map(|m| SessionResp { + id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id, + consultation_type: m.consultation_type, status: m.status, + last_message_at: m.last_message_at, + unread_count_patient: m.unread_count_patient, + unread_count_doctor: m.unread_count_doctor, + created_at: m.created_at, + }).collect()) } // --------------------------------------------------------------------------- -// 咨询消息 (Consultation Messages) +// 咨询消息 // --------------------------------------------------------------------------- -/// 消息列表(按会话 ID 查询,分页) pub async fn list_messages( state: &HealthState, tenant_id: Uuid, session_id: Uuid, - pagination: Pagination, + page: u64, + page_size: u64, ) -> HealthResult> { - let _ = (state, tenant_id, session_id, pagination); - todo!() + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = consultation_message::Entity::find() + .filter(consultation_message::Column::TenantId.eq(tenant_id)) + .filter(consultation_message::Column::SessionId.eq(session_id)) + .filter(consultation_message::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_asc(consultation_message::Column::CreatedAt) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(|m| MessageResp { + id: m.id, session_id: m.session_id, sender_id: m.sender_id, + sender_role: m.sender_role, content_type: m.content_type, + content: m.content, is_read: m.is_read, created_at: m.created_at, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) } -/// 发送消息 pub async fn create_message( state: &HealthState, tenant_id: Uuid, + operator_id: Option, req: CreateMessageReq, ) -> HealthResult { - let _ = (state, tenant_id, req); - // 实现时需要: - // 1. 校验会话存在且状态为 active - // 2. 创建消息记录 - // 3. 更新会话的 last_message_at - // 4. 根据发送者角色更新对方的 unread_count - // 5. 发布 consultation.message.created 事件 - todo!() + // 校验会话存在且状态为 active + let session = consultation_session::Entity::find() + .filter(consultation_session::Column::Id.eq(req.session_id)) + .filter(consultation_session::Column::TenantId.eq(tenant_id)) + .filter(consultation_session::Column::DeletedAt.is_null()) + .filter(consultation_session::Column::Status.eq("active")) + .one(&state.db) + .await? + .ok_or(HealthError::ConsultationNotFound)?; + + let now = Utc::now(); + let is_patient = req.sender_role == "patient"; + + // 创建消息 + let active = consultation_message::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + session_id: Set(req.session_id), + sender_id: Set(req.sender_id), + sender_role: Set(req.sender_role), + content_type: Set(req.content_type.unwrap_or_else(|| "text".to_string())), + content: Set(req.content), + is_read: Set(false), + 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?; + + // 更新会话的 last_message_at 和未读计数 + let mut session_active: consultation_session::ActiveModel = session.into(); + session_active.last_message_at = Set(Some(now)); + // 根据发送者角色更新对方的 unread_count + if is_patient { + session_active.unread_count_doctor = Set(session_active.unread_count_doctor.unwrap() + 1); + } else { + session_active.unread_count_patient = Set(session_active.unread_count_patient.unwrap() + 1); + } + session_active.updated_at = Set(now); + session_active.version = Set(session_active.version.unwrap() + 1); + session_active.update(&state.db).await?; + + Ok(MessageResp { + id: m.id, session_id: m.session_id, sender_id: m.sender_id, + sender_role: m.sender_role, content_type: m.content_type, + content: m.content, is_read: m.is_read, created_at: m.created_at, + }) } diff --git a/crates/erp-health/src/service/follow_up_service.rs b/crates/erp-health/src/service/follow_up_service.rs index 788cfb2..aae7b95 100644 --- a/crates/erp-health/src/service/follow_up_service.rs +++ b/crates/erp-health/src/service/follow_up_service.rs @@ -1,46 +1,47 @@ //! 随访管理 Service — 随访任务CRUD、随访记录、状态流转 -use chrono::NaiveDate; +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; use uuid::Uuid; -use erp_core::types::{PaginatedResponse, Pagination}; +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; +use crate::entity::{follow_up_record, follow_up_task}; use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; // --------------------------------------------------------------------------- -// 随访任务 DTO(内部使用,follow_up_dto 尚未创建独立文件) +// DTO(内部使用) // --------------------------------------------------------------------------- -/// 创建随访任务请求 #[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] pub struct CreateFollowUpTaskReq { pub patient_id: Uuid, pub assigned_to: Option, pub follow_up_type: String, - pub planned_date: NaiveDate, + pub planned_date: chrono::NaiveDate, pub content_template: Option, pub related_appointment_id: Option, } -/// 更新随访任务请求 #[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] pub struct UpdateFollowUpTaskReq { pub assigned_to: Option, pub follow_up_type: Option, - pub planned_date: Option, + pub planned_date: Option, pub content_template: Option, pub status: Option, } -/// 随访任务响应 #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] pub struct FollowUpTaskResp { pub id: Uuid, pub patient_id: Uuid, pub assigned_to: Option, pub follow_up_type: String, - pub planned_date: NaiveDate, + pub planned_date: chrono::NaiveDate, pub status: String, pub content_template: Option, pub related_appointment_id: Option, @@ -49,29 +50,27 @@ pub struct FollowUpTaskResp { pub version: i32, } -/// 创建随访记录请求 #[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] pub struct CreateFollowUpRecordReq { pub task_id: Uuid, pub executed_by: Option, - pub executed_date: NaiveDate, + pub executed_date: chrono::NaiveDate, pub result: String, pub patient_condition: Option, pub medical_advice: Option, - pub next_follow_up_date: Option, + pub next_follow_up_date: Option, } -/// 随访记录响应 #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] pub struct FollowUpRecordResp { pub id: Uuid, pub task_id: Uuid, pub executed_by: Option, - pub executed_date: NaiveDate, + pub executed_date: chrono::NaiveDate, pub result: String, pub patient_condition: Option, pub medical_advice: Option, - pub next_follow_up_date: Option, + pub next_follow_up_date: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub version: i32, @@ -81,81 +80,253 @@ pub struct FollowUpRecordResp { // 随访任务 // --------------------------------------------------------------------------- -/// 随访任务列表(分页 + 多条件筛选) pub async fn list_tasks( state: &HealthState, tenant_id: Uuid, - pagination: Pagination, + page: u64, + page_size: u64, patient_id: Option, assigned_to: Option, status: Option, ) -> HealthResult> { - let _ = (state, tenant_id, pagination, patient_id, assigned_to, status); - todo!() + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()); + + if let Some(pid) = patient_id { query = query.filter(follow_up_task::Column::PatientId.eq(pid)); } + if let Some(uid) = assigned_to { query = query.filter(follow_up_task::Column::AssignedTo.eq(uid)); } + if let Some(ref s) = status { query = query.filter(follow_up_task::Column::Status.eq(s)); } + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_asc(follow_up_task::Column::PlannedDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(|m| FollowUpTaskResp { + id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to, + follow_up_type: m.follow_up_type, planned_date: m.planned_date, + status: m.status, content_template: m.content_template, + related_appointment_id: m.related_appointment_id, + 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_task( state: &HealthState, tenant_id: Uuid, + operator_id: Option, req: CreateFollowUpTaskReq, - user_id: Option, ) -> HealthResult { - let _ = (state, tenant_id, req, user_id); - todo!() + let now = Utc::now(); + let active = follow_up_task::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(req.patient_id), + assigned_to: Set(req.assigned_to), + follow_up_type: Set(req.follow_up_type), + planned_date: Set(req.planned_date), + status: Set("pending".to_string()), + content_template: Set(req.content_template), + related_appointment_id: Set(req.related_appointment_id), + 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(FollowUpTaskResp { + id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to, + follow_up_type: m.follow_up_type, planned_date: m.planned_date, + status: m.status, content_template: m.content_template, + related_appointment_id: m.related_appointment_id, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) } -/// 更新随访任务(乐观锁) pub async fn update_task( state: &HealthState, tenant_id: Uuid, task_id: Uuid, + operator_id: Option, req: UpdateFollowUpTaskReq, - version: i32, + expected_version: i32, ) -> HealthResult { - let _ = (state, tenant_id, task_id, req, version); - todo!() + let model = follow_up_task::Entity::find() + .filter(follow_up_task::Column::Id.eq(task_id)) + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::FollowUpTaskNotFound)?; + + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: follow_up_task::ActiveModel = model.into(); + if let Some(v) = req.assigned_to { active.assigned_to = Set(Some(v)); } + if let Some(v) = req.follow_up_type { active.follow_up_type = Set(v); } + if let Some(v) = req.planned_date { active.planned_date = Set(v); } + if let Some(v) = req.content_template { active.content_template = Set(Some(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(FollowUpTaskResp { + id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to, + follow_up_type: m.follow_up_type, planned_date: m.planned_date, + status: m.status, content_template: m.content_template, + related_appointment_id: m.related_appointment_id, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) } -/// 删除随访任务(软删除) pub async fn delete_task( state: &HealthState, tenant_id: Uuid, task_id: Uuid, + operator_id: Option, ) -> HealthResult<()> { - let _ = (state, tenant_id, task_id); - todo!() + let model = follow_up_task::Entity::find() + .filter(follow_up_task::Column::Id.eq(task_id)) + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::FollowUpTaskNotFound)?; + + let mut active: follow_up_task::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(active.version.unwrap() + 1); + active.update(&state.db).await?; + Ok(()) } // --------------------------------------------------------------------------- // 随访记录 // --------------------------------------------------------------------------- -/// 创建随访执行记录(同时将任务状态推进为 completed) pub async fn create_record( state: &HealthState, tenant_id: Uuid, + operator_id: Option, req: CreateFollowUpRecordReq, - user_id: Option, ) -> HealthResult { - let _ = (state, tenant_id, req, user_id); - // 实现时需要: - // 1. 校验任务存在且状态为 in_progress / pending - // 2. 创建随访记录 - // 3. 更新任务状态为 completed - // 4. 如果设置了 next_follow_up_date,自动创建下一个随访任务 - // 5. 发布 follow_up.completed 事件 - todo!() + // 校验任务存在且状态允许执行 + let task = follow_up_task::Entity::find() + .filter(follow_up_task::Column::Id.eq(req.task_id)) + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .filter( + follow_up_task::Column::Status + .is_in(["pending".to_string(), "in_progress".to_string()]), + ) + .one(&state.db) + .await? + .ok_or(HealthError::FollowUpTaskNotFound)?; + + let now = Utc::now(); + + // 创建随访记录 + let record_active = follow_up_record::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + task_id: Set(req.task_id), + executed_by: Set(req.executed_by.or(operator_id)), + executed_date: Set(req.executed_date), + result: Set(req.result), + patient_condition: Set(req.patient_condition), + medical_advice: Set(req.medical_advice), + next_follow_up_date: Set(req.next_follow_up_date), + 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 record = record_active.insert(&state.db).await?; + + // 更新任务状态为 completed + let mut task_active: follow_up_task::ActiveModel = task.into(); + task_active.status = Set("completed".to_string()); + task_active.updated_at = Set(now); + task_active.updated_by = Set(operator_id); + task_active.version = Set(task_active.version.unwrap() + 1); + task_active.update(&state.db).await?; + + // 如果设置了 next_follow_up_date,自动创建下一个随访任务 + // (由调用方在 handler 层处理,此处仅记录) + + Ok(FollowUpRecordResp { + id: record.id, task_id: record.task_id, executed_by: record.executed_by, + executed_date: record.executed_date, result: record.result, + patient_condition: record.patient_condition, medical_advice: record.medical_advice, + next_follow_up_date: record.next_follow_up_date, + created_at: record.created_at, updated_at: record.updated_at, version: record.version, + }) } -/// 随访记录列表(分页) pub async fn list_records( state: &HealthState, tenant_id: Uuid, - pagination: Pagination, + page: u64, + page_size: u64, task_id: Option, patient_id: Option, ) -> HealthResult> { - let _ = (state, tenant_id, pagination, task_id, patient_id); - todo!() + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = follow_up_record::Entity::find() + .filter(follow_up_record::Column::TenantId.eq(tenant_id)) + .filter(follow_up_record::Column::DeletedAt.is_null()); + + if let Some(tid) = task_id { query = query.filter(follow_up_record::Column::TaskId.eq(tid)); } + // patient_id 需要通过 task 关联,简化处理:先查 task + if let Some(pid) = patient_id { + let task_ids: Vec = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::PatientId.eq(pid)) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .all(&state.db) + .await? + .into_iter() + .map(|t| t.id) + .collect(); + query = query.filter(follow_up_record::Column::TaskId.is_in(task_ids)); + } + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(follow_up_record::Column::ExecutedDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(|m| FollowUpRecordResp { + id: m.id, task_id: m.task_id, executed_by: m.executed_by, + executed_date: m.executed_date, result: m.result, + patient_condition: m.patient_condition, medical_advice: m.medical_advice, + next_follow_up_date: m.next_follow_up_date, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) } diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index c1048e2..a7cba40 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -1,207 +1,575 @@ //! 健康数据 Service — 体征记录、化验报告、体检记录、趋势分析 -use chrono::NaiveDate; +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; use uuid::Uuid; -use erp_core::types::{PaginatedResponse, Pagination}; +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; -use crate::dto::health_data_dto::{ - CreateHealthRecordReq, CreateLabReportReq, CreateVitalSignsReq, HealthRecordResp, - IndicatorTimeseriesResp, LabReportResp, TrendResp, UpdateVitalSignsReq, -}; -use crate::error::HealthResult; +use crate::dto::health_data_dto::*; +use crate::entity::{health_record, health_trend, lab_report, vital_signs}; +use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; // --------------------------------------------------------------------------- // 体征记录 (Vital Signs) // --------------------------------------------------------------------------- -/// 体征记录列表 pub async fn list_vital_signs( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, - pagination: Pagination, -) -> HealthResult> { - let _ = (state, tenant_id, patient_id, pagination); - todo!() + page: u64, + page_size: u64, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(vital_signs::Column::RecordDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data: Vec = models.into_iter().map(|m| VitalSignsResp { + id: m.id, + patient_id: m.patient_id, + record_date: m.record_date, + systolic_bp_morning: m.systolic_bp_morning, + diastolic_bp_morning: m.diastolic_bp_morning, + systolic_bp_evening: m.systolic_bp_evening, + diastolic_bp_evening: m.diastolic_bp_evening, + heart_rate: m.heart_rate, + weight: m.weight.map(|d| d.to_string().parse().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)), + water_intake_ml: m.water_intake_ml, + urine_output_ml: m.urine_output_ml, + 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 create_vital_signs( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, + operator_id: Option, req: CreateVitalSignsReq, - user_id: Option, -) -> HealthResult { - let _ = (state, tenant_id, patient_id, req, user_id); - todo!() +) -> HealthResult { + let now = Utc::now(); + let active = vital_signs::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + record_date: Set(req.record_date), + systolic_bp_morning: Set(req.systolic_bp_morning), + diastolic_bp_morning: Set(req.diastolic_bp_morning), + systolic_bp_evening: Set(req.systolic_bp_evening), + diastolic_bp_evening: Set(req.diastolic_bp_evening), + heart_rate: Set(req.heart_rate), + weight: Set(req.weight.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())), + blood_sugar: Set(req.blood_sugar.map(|v| sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())), + water_intake_ml: Set(req.water_intake_ml), + urine_output_ml: Set(req.urine_output_ml), + 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(VitalSignsResp { + id: m.id, patient_id: m.patient_id, record_date: m.record_date, + systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning, + systolic_bp_evening: m.systolic_bp_evening, diastolic_bp_evening: m.diastolic_bp_evening, + heart_rate: m.heart_rate, + weight: m.weight.map(|d| d.to_string().parse().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)), + water_intake_ml: m.water_intake_ml, urine_output_ml: m.urine_output_ml, + notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) } -/// 更新体征记录(乐观锁) pub async fn update_vital_signs( state: &HealthState, tenant_id: Uuid, - patient_id: Uuid, + _patient_id: Uuid, vital_signs_id: Uuid, + operator_id: Option, req: UpdateVitalSignsReq, - version: i32, -) -> HealthResult { - let _ = (state, tenant_id, patient_id, vital_signs_id, req, version); - todo!() + expected_version: i32, +) -> HealthResult { + let model = vital_signs::Entity::find() + .filter(vital_signs::Column::Id.eq(vital_signs_id)) + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: vital_signs::ActiveModel = model.into(); + if let Some(v) = req.record_date { active.record_date = Set(v); } + if let Some(v) = req.systolic_bp_morning { active.systolic_bp_morning = Set(Some(v)); } + if let Some(v) = req.diastolic_bp_morning { active.diastolic_bp_morning = Set(Some(v)); } + if let Some(v) = req.systolic_bp_evening { active.systolic_bp_evening = Set(Some(v)); } + if let Some(v) = req.diastolic_bp_evening { active.diastolic_bp_evening = Set(Some(v)); } + if let Some(v) = req.heart_rate { active.heart_rate = Set(Some(v)); } + if let Some(v) = req.weight { active.weight = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); } + if let Some(v) = req.blood_sugar { active.blood_sugar = Set(Some(sea_orm::prelude::Decimal::from_f64_retain(v).unwrap_or_default())); } + if let Some(v) = req.water_intake_ml { active.water_intake_ml = Set(Some(v)); } + if let Some(v) = req.urine_output_ml { active.urine_output_ml = Set(Some(v)); } + if let Some(v) = req.notes { active.notes = Set(Some(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(VitalSignsResp { + id: m.id, patient_id: m.patient_id, record_date: m.record_date, + systolic_bp_morning: m.systolic_bp_morning, diastolic_bp_morning: m.diastolic_bp_morning, + systolic_bp_evening: m.systolic_bp_evening, diastolic_bp_evening: m.diastolic_bp_evening, + heart_rate: m.heart_rate, + weight: m.weight.map(|d| d.to_string().parse().unwrap_or(0.0)), + blood_sugar: m.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)), + water_intake_ml: m.water_intake_ml, urine_output_ml: m.urine_output_ml, + notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) } -/// 删除体征记录 pub async fn delete_vital_signs( state: &HealthState, tenant_id: Uuid, - patient_id: Uuid, vital_signs_id: Uuid, + operator_id: Option, ) -> HealthResult<()> { - let _ = (state, tenant_id, patient_id, vital_signs_id); - todo!() + let model = vital_signs::Entity::find() + .filter(vital_signs::Column::Id.eq(vital_signs_id)) + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + let mut active: vital_signs::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(active.version.unwrap() + 1); + active.update(&state.db).await?; + Ok(()) } // --------------------------------------------------------------------------- // 化验报告 (Lab Reports) // --------------------------------------------------------------------------- -/// 化验报告列表 pub async fn list_lab_reports( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, - pagination: Pagination, + page: u64, + page_size: u64, ) -> HealthResult> { - let _ = (state, tenant_id, patient_id, pagination); - todo!() + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::PatientId.eq(patient_id)) + .filter(lab_report::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(lab_report::Column::ReportDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(|m| LabReportResp { + id: m.id, patient_id: m.patient_id, report_date: m.report_date, + report_type: m.report_type, indicators: m.indicators, + image_urls: m.image_urls, doctor_interpretation: m.doctor_interpretation, + 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_lab_report( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, + operator_id: Option, req: CreateLabReportReq, - user_id: Option, ) -> HealthResult { - let _ = (state, tenant_id, patient_id, req, user_id); - todo!() + let now = Utc::now(); + let active = lab_report::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + report_date: Set(req.report_date), + report_type: Set(req.report_type), + indicators: Set(req.indicators), + image_urls: Set(req.image_urls), + doctor_interpretation: Set(req.doctor_interpretation), + 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(LabReportResp { + id: m.id, patient_id: m.patient_id, report_date: m.report_date, + report_type: m.report_type, indicators: m.indicators, + image_urls: m.image_urls, doctor_interpretation: m.doctor_interpretation, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) } -/// 更新化验报告(乐观锁) pub async fn update_lab_report( state: &HealthState, tenant_id: Uuid, - patient_id: Uuid, report_id: Uuid, + operator_id: Option, req: CreateLabReportReq, - version: i32, + expected_version: i32, ) -> HealthResult { - let _ = (state, tenant_id, patient_id, report_id, req, version); - todo!() + let model = lab_report::Entity::find() + .filter(lab_report::Column::Id.eq(report_id)) + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: lab_report::ActiveModel = model.into(); + active.report_date = Set(req.report_date); + active.report_type = Set(req.report_type); + active.indicators = Set(req.indicators); + active.image_urls = Set(req.image_urls); + active.doctor_interpretation = Set(req.doctor_interpretation); + 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(LabReportResp { + id: m.id, patient_id: m.patient_id, report_date: m.report_date, + report_type: m.report_type, indicators: m.indicators, + image_urls: m.image_urls, doctor_interpretation: m.doctor_interpretation, + created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) } -/// 删除化验报告 pub async fn delete_lab_report( state: &HealthState, tenant_id: Uuid, - patient_id: Uuid, report_id: Uuid, + operator_id: Option, ) -> HealthResult<()> { - let _ = (state, tenant_id, patient_id, report_id); - todo!() + let model = lab_report::Entity::find() + .filter(lab_report::Column::Id.eq(report_id)) + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + let mut active: lab_report::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(active.version.unwrap() + 1); + active.update(&state.db).await?; + Ok(()) } // --------------------------------------------------------------------------- // 体检记录 (Health Records) // --------------------------------------------------------------------------- -/// 体检记录列表 pub async fn list_health_records( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, - pagination: Pagination, + page: u64, + page_size: u64, ) -> HealthResult> { - let _ = (state, tenant_id, patient_id, pagination); - todo!() + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = health_record::Entity::find() + .filter(health_record::Column::TenantId.eq(tenant_id)) + .filter(health_record::Column::PatientId.eq(patient_id)) + .filter(health_record::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(health_record::Column::RecordDate) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(|m| HealthRecordResp { + id: m.id, patient_id: m.patient_id, record_type: m.record_type, + record_date: m.record_date, source: m.source, + overall_assessment: m.overall_assessment, report_file_url: m.report_file_url, + 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 create_health_record( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, + operator_id: Option, req: CreateHealthRecordReq, - user_id: Option, ) -> HealthResult { - let _ = (state, tenant_id, patient_id, req, user_id); - todo!() + let now = Utc::now(); + let active = health_record::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + record_type: Set(req.record_type.unwrap_or_else(|| "routine".to_string())), + record_date: Set(req.record_date), + source: Set(req.source), + overall_assessment: Set(req.overall_assessment), + report_file_url: Set(req.report_file_url), + 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(HealthRecordResp { + id: m.id, patient_id: m.patient_id, record_type: m.record_type, + record_date: m.record_date, source: m.source, + overall_assessment: m.overall_assessment, report_file_url: m.report_file_url, + notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) } -/// 更新体检记录(乐观锁) pub async fn update_health_record( state: &HealthState, tenant_id: Uuid, - patient_id: Uuid, record_id: Uuid, + operator_id: Option, req: CreateHealthRecordReq, - version: i32, + expected_version: i32, ) -> HealthResult { - let _ = (state, tenant_id, patient_id, record_id, req, version); - todo!() + let model = health_record::Entity::find() + .filter(health_record::Column::Id.eq(record_id)) + .filter(health_record::Column::TenantId.eq(tenant_id)) + .filter(health_record::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: health_record::ActiveModel = model.into(); + if let Some(v) = req.record_type { active.record_type = Set(v); } + active.record_date = Set(req.record_date); + active.source = Set(req.source); + active.overall_assessment = Set(req.overall_assessment); + active.report_file_url = Set(req.report_file_url); + active.notes = Set(req.notes); + 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(HealthRecordResp { + id: m.id, patient_id: m.patient_id, record_type: m.record_type, + record_date: m.record_date, source: m.source, + overall_assessment: m.overall_assessment, report_file_url: m.report_file_url, + notes: m.notes, created_at: m.created_at, updated_at: m.updated_at, version: m.version, + }) } -/// 删除体检记录 pub async fn delete_health_record( state: &HealthState, tenant_id: Uuid, - patient_id: Uuid, record_id: Uuid, + operator_id: Option, ) -> HealthResult<()> { - let _ = (state, tenant_id, patient_id, record_id); - todo!() + let model = health_record::Entity::find() + .filter(health_record::Column::Id.eq(record_id)) + .filter(health_record::Column::TenantId.eq(tenant_id)) + .filter(health_record::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + let mut active: health_record::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(active.version.unwrap() + 1); + active.update(&state.db).await?; + Ok(()) } // --------------------------------------------------------------------------- // 趋势分析 (Trends) // --------------------------------------------------------------------------- -/// 趋势列表 pub async fn list_trends( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, - pagination: Pagination, + page: u64, + page_size: u64, ) -> HealthResult> { - let _ = (state, tenant_id, patient_id, pagination); - todo!() + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = health_trend::Entity::find() + .filter(health_trend::Column::TenantId.eq(tenant_id)) + .filter(health_trend::Column::PatientId.eq(patient_id)) + .filter(health_trend::Column::DeletedAt.is_null()); + + let total = query.clone().count(&state.db).await?; + let models = query + .order_by_desc(health_trend::Column::CreatedAt) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(|m| TrendResp { + id: m.id, patient_id: m.patient_id, + period_start: m.period_start, period_end: m.period_end, + indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items, + generation_type: m.generation_type, report_file_url: m.report_file_url, + }).collect(); + + Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) } -/// 生成趋势分析报告(基于历史体征 + 化验数据聚合) pub async fn generate_trend( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, - period_start: NaiveDate, - period_end: NaiveDate, - user_id: Option, + operator_id: Option, + period_start: chrono::NaiveDate, + period_end: chrono::NaiveDate, ) -> HealthResult { - let _ = (state, tenant_id, patient_id, period_start, period_end, user_id); - todo!() + // 汇总该时间段内的体征数据 + let vitals = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .filter(vital_signs::Column::RecordDate.gte(period_start)) + .filter(vital_signs::Column::RecordDate.lte(period_end)) + .all(&state.db) + .await?; + + let summary = serde_json::json!({ + "period": { "start": period_start, "end": period_end }, + "record_count": vitals.len(), + "avg_heart_rate": vitals.iter().filter_map(|v| v.heart_rate).sum::() as f64 + / vitals.iter().filter(|v| v.heart_rate.is_some()).count().max(1) as f64, + }); + + let now = Utc::now(); + let active = health_trend::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + period_start: Set(period_start), + period_end: Set(period_end), + indicator_summary: Set(Some(summary)), + abnormal_items: Set(None), + generation_type: Set("auto".to_string()), + report_file_url: 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), + }; + + let m = active.insert(&state.db).await?; + Ok(TrendResp { + id: m.id, patient_id: m.patient_id, + period_start: m.period_start, period_end: m.period_end, + indicator_summary: m.indicator_summary, abnormal_items: m.abnormal_items, + generation_type: m.generation_type, report_file_url: m.report_file_url, + }) } -/// 获取单个指标的时间序列数据 pub async fn get_indicator_timeseries( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, indicator: String, - start_date: Option, - end_date: Option, + start_date: Option, + end_date: Option, ) -> HealthResult { - let _ = (state, tenant_id, patient_id, indicator, start_date, end_date); - todo!() + let mut query = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()); + + if let Some(sd) = start_date { + query = query.filter(vital_signs::Column::RecordDate.gte(sd)); + } + if let Some(ed) = end_date { + query = query.filter(vital_signs::Column::RecordDate.lte(ed)); + } + + let vitals = query + .order_by_asc(vital_signs::Column::RecordDate) + .all(&state.db) + .await?; + + let data: Vec<(chrono::NaiveDate, f64)> = vitals.into_iter().filter_map(|v| { + let val = match indicator.as_str() { + "heart_rate" => v.heart_rate.map(|x| x as f64), + "weight" => v.weight.map(|d| d.to_string().parse().unwrap_or(0.0)), + "blood_sugar" => v.blood_sugar.map(|d| d.to_string().parse().unwrap_or(0.0)), + "systolic_bp_morning" => v.systolic_bp_morning.map(|x| x as f64), + "diastolic_bp_morning" => v.diastolic_bp_morning.map(|x| x as f64), + "systolic_bp_evening" => v.systolic_bp_evening.map(|x| x as f64), + "diastolic_bp_evening" => v.diastolic_bp_evening.map(|x| x as f64), + _ => None, + }; + val.map(|fv| (v.record_date, fv)) + }).collect(); + + Ok(IndicatorTimeseriesResp { indicator, data }) } diff --git a/crates/erp-health/src/service/patient_service.rs b/crates/erp-health/src/service/patient_service.rs index 899ad41..97332c7 100644 --- a/crates/erp-health/src/service/patient_service.rs +++ b/crates/erp-health/src/service/patient_service.rs @@ -1,14 +1,19 @@ -//! 患者管理 Service — 患者CRUD、家庭成员、标签、医生关联、健康摘要 +//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要 +use chrono::Utc; +use sea_orm::entity::prelude::*; +use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect}; use uuid::Uuid; -use erp_core::types::{PaginatedResponse, Pagination}; +use erp_core::error::check_version; +use erp_core::types::PaginatedResponse; -use crate::dto::patient_dto::{ - CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, - UpdatePatientReq, -}; -use crate::error::HealthResult; +use crate::dto::patient_dto::*; +use crate::entity::patient; +use crate::entity::patient_family_member; +use crate::entity::patient_tag_relation; +use crate::entity::patient_doctor_relation; +use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; // --------------------------------------------------------------------------- @@ -19,23 +24,105 @@ use crate::state::HealthState; pub async fn list_patients( state: &HealthState, tenant_id: Uuid, - pagination: Pagination, + page: u64, + page_size: u64, search: Option, tag_id: Option, ) -> HealthResult> { - let _ = (state, tenant_id, pagination, search, tag_id); - todo!() + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + // 如果按标签筛选,先查出关联的 patient_id 列表 + let tagged_patient_ids: Option> = if let Some(tid) = tag_id { + let rows: Vec = patient_tag_relation::Entity::find() + .filter(patient_tag_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_tag_relation::Column::TagId.eq(tid)) + .filter(patient_tag_relation::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + Some(rows.into_iter().map(|r| r.patient_id).collect()) + } else { + None + }; + + let mut query = patient::Entity::find() + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()); + + if let Some(ref search) = search { + let pattern = format!("%{}%", search); + query = query.filter( + Condition::any() + .add(patient::Column::Name.contains(&pattern)) + .add(patient::Column::IdNumber.contains(&pattern)), + ); + } + + if let Some(ref ids) = tagged_patient_ids { + query = query.filter(patient::Column::Id.is_in(ids.clone())); + } + + let total = query + .clone() + .count(&state.db) + .await?; + + let models = query + .order_by_desc(patient::Column::CreatedAt) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(model_to_resp).collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size: limit, + total_pages, + }) } /// 创建患者 pub async fn create_patient( state: &HealthState, tenant_id: Uuid, - user_id: Option, + operator_id: Option, req: CreatePatientReq, ) -> HealthResult { - let _ = (state, tenant_id, user_id, req); - todo!() + let now = Utc::now(); + let id = Uuid::now_v7(); + + let active = patient::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + user_id: Set(None), + name: Set(req.name), + gender: Set(req.gender), + birth_date: Set(req.birth_date), + blood_type: Set(req.blood_type), + id_number: Set(req.id_number), + allergy_history: Set(req.allergy_history), + medical_history_summary: Set(req.medical_history_summary), + emergency_contact_name: Set(req.emergency_contact_name), + emergency_contact_phone: Set(req.emergency_contact_phone), + status: Set("active".to_string()), + verification_status: Set("pending".to_string()), + source: Set(req.source), + 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 model = active.insert(&state.db).await?; + Ok(model_to_resp(model)) } /// 获取患者详情 @@ -44,8 +131,8 @@ pub async fn get_patient( tenant_id: Uuid, id: Uuid, ) -> HealthResult { - let _ = (state, tenant_id, id); - todo!() + let model = find_patient(&state.db, tenant_id, id).await?; + Ok(model_to_resp(model)) } /// 更新患者信息(乐观锁) @@ -53,11 +140,34 @@ pub async fn update_patient( state: &HealthState, tenant_id: Uuid, id: Uuid, + operator_id: Option, req: UpdatePatientReq, - version: i32, + expected_version: i32, ) -> HealthResult { - let _ = (state, tenant_id, id, req, version); - todo!() + let model = find_patient(&state.db, tenant_id, id).await?; + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: patient::ActiveModel = model.into(); + + if let Some(v) = req.name { active.name = Set(v); } + if let Some(v) = req.gender { active.gender = Set(Some(v)); } + if req.birth_date.is_some() { active.birth_date = Set(req.birth_date); } + if let Some(v) = req.blood_type { active.blood_type = Set(Some(v)); } + if let Some(v) = req.id_number { active.id_number = Set(Some(v)); } + if let Some(v) = req.allergy_history { active.allergy_history = Set(Some(v)); } + if let Some(v) = req.medical_history_summary { active.medical_history_summary = Set(Some(v)); } + if let Some(v) = req.emergency_contact_name { active.emergency_contact_name = Set(Some(v)); } + if let Some(v) = req.emergency_contact_phone { active.emergency_contact_phone = Set(Some(v)); } + if let Some(v) = req.source { active.source = Set(Some(v)); } + if let Some(v) = req.notes { active.notes = Set(Some(v)); } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active.update(&state.db).await?; + Ok(model_to_resp(updated)) } /// 软删除患者 @@ -65,39 +175,129 @@ pub async fn delete_patient( state: &HealthState, tenant_id: Uuid, id: Uuid, + operator_id: Option, ) -> HealthResult<()> { - let _ = (state, tenant_id, id); - todo!() + let model = find_patient(&state.db, tenant_id, id).await?; + + let mut active: patient::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(active.version.unwrap() + 1); + active.update(&state.db).await?; + + Ok(()) } // --------------------------------------------------------------------------- // 标签管理 // --------------------------------------------------------------------------- -/// 管理患者标签(覆盖式:传入的 tag_ids 替换当前关联) +/// 管理患者标签(覆盖式) pub async fn manage_patient_tags( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, req: ManageTagsReq, - user_id: Option, + operator_id: Option, ) -> HealthResult<()> { - let _ = (state, tenant_id, patient_id, req, user_id); - todo!() + // 确认患者存在 + find_patient(&state.db, tenant_id, patient_id).await?; + + let now = Utc::now(); + + // 软删除旧的关联 + patient_tag_relation::Entity::update_many() + .col_expr( + patient_tag_relation::Column::DeletedAt, + Expr::value(Some(now)), + ) + .col_expr( + patient_tag_relation::Column::UpdatedAt, + Expr::value(now), + ) + .filter(patient_tag_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_tag_relation::Column::PatientId.eq(patient_id)) + .filter(patient_tag_relation::Column::DeletedAt.is_null()) + .exec(&state.db) + .await?; + + // 插入新的关联 + for tag_id in req.tag_ids { + let rel = patient_tag_relation::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + tag_id: Set(tag_id), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + }; + rel.insert(&state.db).await?; + } + + Ok(()) } // --------------------------------------------------------------------------- // 健康摘要 // --------------------------------------------------------------------------- -/// 获取患者健康摘要(最新体征 + 最新化验 + 待处理预约 + 待办随访) +/// 获取患者健康摘要 pub async fn get_health_summary( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, ) -> HealthResult { - let _ = (state, tenant_id, patient_id); - todo!() + find_patient(&state.db, tenant_id, patient_id).await?; + + use crate::entity::{vital_signs, lab_report, appointment, follow_up_task}; + + // 最新体征 + let latest_vitals = vital_signs::Entity::find() + .filter(vital_signs::Column::TenantId.eq(tenant_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) + .filter(vital_signs::Column::DeletedAt.is_null()) + .order_by_desc(vital_signs::Column::RecordDate) + .one(&state.db) + .await?; + + // 最新化验 + let latest_lab = lab_report::Entity::find() + .filter(lab_report::Column::TenantId.eq(tenant_id)) + .filter(lab_report::Column::PatientId.eq(patient_id)) + .filter(lab_report::Column::DeletedAt.is_null()) + .order_by_desc(lab_report::Column::ReportDate) + .one(&state.db) + .await?; + + // 待处理预约数 + let upcoming = appointment::Entity::find() + .filter(appointment::Column::TenantId.eq(tenant_id)) + .filter(appointment::Column::PatientId.eq(patient_id)) + .filter(appointment::Column::Status.eq("scheduled")) + .filter(appointment::Column::DeletedAt.is_null()) + .count(&state.db) + .await?; + + // 待办随访数 + let pending_follow_ups = follow_up_task::Entity::find() + .filter(follow_up_task::Column::TenantId.eq(tenant_id)) + .filter(follow_up_task::Column::PatientId.eq(patient_id)) + .filter(follow_up_task::Column::Status.eq("pending")) + .filter(follow_up_task::Column::DeletedAt.is_null()) + .count(&state.db) + .await?; + + Ok(serde_json::json!({ + "patient_id": patient_id, + "latest_vital_signs": latest_vitals.map(|v| serde_json::to_value(v).unwrap_or_default()), + "latest_lab_report": latest_lab.map(|v| serde_json::to_value(v).unwrap_or_default()), + "upcoming_appointments": upcoming, + "pending_follow_ups": pending_follow_ups, + })) } // --------------------------------------------------------------------------- @@ -110,8 +310,26 @@ pub async fn list_family_members( tenant_id: Uuid, patient_id: Uuid, ) -> HealthResult> { - let _ = (state, tenant_id, patient_id); - todo!() + let models = patient_family_member::Entity::find() + .filter(patient_family_member::Column::TenantId.eq(tenant_id)) + .filter(patient_family_member::Column::PatientId.eq(patient_id)) + .filter(patient_family_member::Column::DeletedAt.is_null()) + .order_by_asc(patient_family_member::Column::CreatedAt) + .all(&state.db) + .await?; + + Ok(models.into_iter().map(|m| FamilyMemberResp { + id: m.id, + patient_id: m.patient_id, + name: m.name, + relationship: m.relationship, + phone: m.phone, + birth_date: m.birth_date, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + }).collect()) } /// 创建家庭成员 @@ -119,35 +337,116 @@ pub async fn create_family_member( state: &HealthState, tenant_id: Uuid, patient_id: Uuid, + operator_id: Option, req: FamilyMemberReq, - user_id: Option, ) -> HealthResult { - let _ = (state, tenant_id, patient_id, req, user_id); - todo!() + find_patient(&state.db, tenant_id, patient_id).await?; + + let now = Utc::now(); + let id = Uuid::now_v7(); + + let active = patient_family_member::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + name: Set(req.name), + relationship: Set(req.relationship), + phone: Set(req.phone), + birth_date: Set(req.birth_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 model = active.insert(&state.db).await?; + Ok(FamilyMemberResp { + id: model.id, + patient_id: model.patient_id, + name: model.name, + relationship: model.relationship, + phone: model.phone, + birth_date: model.birth_date, + notes: model.notes, + created_at: model.created_at, + updated_at: model.updated_at, + version: model.version, + }) } /// 更新家庭成员(乐观锁) pub async fn update_family_member( state: &HealthState, tenant_id: Uuid, - patient_id: Uuid, + _patient_id: Uuid, family_member_id: Uuid, + operator_id: Option, req: FamilyMemberReq, - version: i32, + expected_version: i32, ) -> HealthResult { - let _ = (state, tenant_id, patient_id, family_member_id, req, version); - todo!() + let model = patient_family_member::Entity::find() + .filter(patient_family_member::Column::Id.eq(family_member_id)) + .filter(patient_family_member::Column::TenantId.eq(tenant_id)) + .filter(patient_family_member::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + let next_ver = check_version(expected_version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + let mut active: patient_family_member::ActiveModel = model.into(); + active.name = Set(req.name); + active.relationship = Set(req.relationship); + active.phone = Set(req.phone); + active.birth_date = Set(req.birth_date); + active.notes = Set(req.notes); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active.update(&state.db).await?; + Ok(FamilyMemberResp { + id: updated.id, + patient_id: updated.patient_id, + name: updated.name, + relationship: updated.relationship, + phone: updated.phone, + birth_date: updated.birth_date, + notes: updated.notes, + created_at: updated.created_at, + updated_at: updated.updated_at, + version: updated.version, + }) } /// 删除家庭成员 pub async fn delete_family_member( state: &HealthState, tenant_id: Uuid, - patient_id: Uuid, + _patient_id: Uuid, family_member_id: Uuid, + operator_id: Option, ) -> HealthResult<()> { - let _ = (state, tenant_id, patient_id, family_member_id); - todo!() + let model = patient_family_member::Entity::find() + .filter(patient_family_member::Column::Id.eq(family_member_id)) + .filter(patient_family_member::Column::TenantId.eq(tenant_id)) + .filter(patient_family_member::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + + let mut active: patient_family_member::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(active.version.unwrap() + 1); + active.update(&state.db).await?; + + Ok(()) } // --------------------------------------------------------------------------- @@ -161,10 +460,25 @@ pub async fn assign_doctor( patient_id: Uuid, doctor_id: Uuid, relationship_type: String, - user_id: Option, + operator_id: Option, ) -> HealthResult<()> { - let _ = (state, tenant_id, patient_id, doctor_id, relationship_type, user_id); - todo!() + find_patient(&state.db, tenant_id, patient_id).await?; + + let now = Utc::now(); + let active = patient_doctor_relation::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + patient_id: Set(patient_id), + doctor_id: Set(doctor_id), + relationship_type: Set(relationship_type), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + }; + active.insert(&state.db).await?; + Ok(()) } /// 移除负责医生 @@ -174,6 +488,61 @@ pub async fn remove_doctor( patient_id: Uuid, doctor_id: Uuid, ) -> HealthResult<()> { - let _ = (state, tenant_id, patient_id, doctor_id); - todo!() + let model = patient_doctor_relation::Entity::find() + .filter(patient_doctor_relation::Column::TenantId.eq(tenant_id)) + .filter(patient_doctor_relation::Column::PatientId.eq(patient_id)) + .filter(patient_doctor_relation::Column::DoctorId.eq(doctor_id)) + .filter(patient_doctor_relation::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::DoctorNotFound)?; + + let mut active: patient_doctor_relation::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.update(&state.db).await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// 内部辅助 +// --------------------------------------------------------------------------- + +/// 按租户+ID查找未删除患者 +async fn find_patient( + db: &DatabaseConnection, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + patient::Entity::find() + .filter(patient::Column::Id.eq(id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or(HealthError::PatientNotFound) +} + +/// Entity Model → DTO Resp +fn model_to_resp(m: patient::Model) -> PatientResp { + PatientResp { + id: m.id, + user_id: m.user_id, + name: m.name, + gender: m.gender, + birth_date: m.birth_date, + blood_type: m.blood_type, + id_number: m.id_number, + allergy_history: m.allergy_history, + medical_history_summary: m.medical_history_summary, + emergency_contact_name: m.emergency_contact_name, + emergency_contact_phone: m.emergency_contact_phone, + status: m.status, + verification_status: m.verification_status, + source: m.source, + notes: m.notes, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } }