feat(ai): Phase 2A-3 随访页 AI 辅助生成小结 — SSE 端点 + 前端集成

- AnalysisType 新增 FollowUpSummary 变体(as_str/prompt_name)
- HealthDataProvider 新增 get_follow_up_summary_data() + FollowUpSummaryDataDto
- erp-health 实现随访数据查询(task + records + PII 解密)
- 新增 /ai/analyze/follow-up-summary SSE 端点
- SanitizationService 新增 sanitize_follow_up_data()
- 前端 analysisSse.ts/AiAnalysisCard 支持 follow-up-summary 类型
- FollowUpTaskList 操作列新增「AI 小结」按钮
This commit is contained in:
iven
2026-05-19 00:54:15 +08:00
parent 205f6fb5a2
commit 2660f1afff
10 changed files with 223 additions and 13 deletions

View File

@@ -3,17 +3,18 @@ use chrono::Datelike;
use erp_core::crypto::{self as pii, PiiCrypto};
use erp_core::error::{AppError, AppResult};
use erp_core::health_provider::{
AnomalyInfo, AppointmentSummaryDto, HealthDataProvider, HealthReportDto, LabItemDto,
LabReportDto, LabReportListItemDto, MedicationSummaryDto, MetricTrendAnalysis,
PatientSummaryDto, RegressionStats, ReportSectionDto, TimeRange, TrendAnalysisDto,
TrendDirection, VitalSignDto,
AnomalyInfo, AppointmentSummaryDto, FollowUpRecordDto, FollowUpSummaryDataDto,
HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto, LabReportListItemDto,
MedicationSummaryDto, MetricTrendAnalysis, PatientSummaryDto, RegressionStats,
ReportSectionDto, TimeRange, TrendAnalysisDto, TrendDirection, VitalSignDto,
};
use num_traits::ToPrimitive;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use uuid::Uuid;
use crate::entity::{
appointment, diagnosis, health_record, lab_report, medication_record, patient, vital_signs,
appointment, diagnosis, follow_up_record, follow_up_task, health_record, lab_report,
medication_record, patient, vital_signs,
};
pub struct HealthDataProviderImpl {
@@ -668,4 +669,50 @@ impl HealthDataProvider for HealthDataProviderImpl {
Ok(result)
}
async fn get_follow_up_summary_data(
&self,
tenant_id: Uuid,
task_id: Uuid,
) -> AppResult<FollowUpSummaryDataDto> {
let task = follow_up_task::Entity::find_by_id(task_id)
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.one(&self.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("随访任务 {task_id} 不存在")))?;
let records = follow_up_record::Entity::find()
.filter(follow_up_record::Column::TaskId.eq(task_id))
.filter(follow_up_record::Column::DeletedAt.is_null())
.order_by_asc(follow_up_record::Column::ExecutedDate)
.all(&self.db)
.await?;
let kek = self.crypto.kek();
let record_dtos: Vec<FollowUpRecordDto> = records
.into_iter()
.map(|r| {
let decrypt_text = |enc: &str| -> String {
pii::decrypt(kek, enc).unwrap_or_else(|_| enc.to_string())
};
FollowUpRecordDto {
executed_date: r.executed_date.to_string(),
result: pii::decrypt(kek, &r.result).unwrap_or_else(|_| r.result.clone()),
patient_condition: r.patient_condition.as_deref().map(decrypt_text),
medical_advice: r.medical_advice.as_deref().map(decrypt_text),
next_follow_up_date: r.next_follow_up_date.map(|d| d.to_string()),
}
})
.collect();
Ok(FollowUpSummaryDataDto {
task_id: task.id,
patient_id: task.patient_id,
follow_up_type: task.follow_up_type,
planned_date: task.planned_date.to_string(),
task_status: task.status,
records: record_dtos,
})
}
}