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:
@@ -1,9 +1,10 @@
|
|||||||
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary';
|
export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary' | 'follow-up-summary';
|
||||||
|
|
||||||
interface AnalyzeBody {
|
interface AnalyzeBody {
|
||||||
report_id?: string;
|
report_id?: string;
|
||||||
patient_id?: string;
|
patient_id?: string;
|
||||||
metrics?: string[];
|
metrics?: string[];
|
||||||
|
source_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENDPOINT_MAP: Record<AnalysisType, string> = {
|
const ENDPOINT_MAP: Record<AnalysisType, string> = {
|
||||||
@@ -11,6 +12,7 @@ const ENDPOINT_MAP: Record<AnalysisType, string> = {
|
|||||||
'trends': '/ai/analyze/trends',
|
'trends': '/ai/analyze/trends',
|
||||||
'checkup-plan': '/ai/analyze/checkup-plan',
|
'checkup-plan': '/ai/analyze/checkup-plan',
|
||||||
'report-summary': '/ai/analyze/report-summary',
|
'report-summary': '/ai/analyze/report-summary',
|
||||||
|
'follow-up-summary': '/ai/analyze/follow-up-summary',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SseCallbacks {
|
export interface SseCallbacks {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface AiAnalysisCardProps {
|
|||||||
triggerLabel?: string;
|
triggerLabel?: string;
|
||||||
permission?: string;
|
permission?: string;
|
||||||
metrics?: string[];
|
metrics?: string[];
|
||||||
|
taskId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalysisState = 'idle' | 'loading' | 'success' | 'error';
|
type AnalysisState = 'idle' | 'loading' | 'success' | 'error';
|
||||||
@@ -21,6 +22,7 @@ export function AiAnalysisCard({
|
|||||||
triggerLabel = 'AI 分析',
|
triggerLabel = 'AI 分析',
|
||||||
permission = 'ai.analysis.manage',
|
permission = 'ai.analysis.manage',
|
||||||
metrics,
|
metrics,
|
||||||
|
taskId,
|
||||||
}: AiAnalysisCardProps) {
|
}: AiAnalysisCardProps) {
|
||||||
const [state, setState] = useState<AnalysisState>('idle');
|
const [state, setState] = useState<AnalysisState>('idle');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
@@ -38,6 +40,9 @@ export function AiAnalysisCard({
|
|||||||
if (analysisType === 'trends' || analysisType === 'checkup-plan') {
|
if (analysisType === 'trends' || analysisType === 'checkup-plan') {
|
||||||
body.patient_id = sourceRef;
|
body.patient_id = sourceRef;
|
||||||
}
|
}
|
||||||
|
if (analysisType === 'follow-up-summary') {
|
||||||
|
body.source_id = taskId || sourceRef;
|
||||||
|
}
|
||||||
if (metrics) {
|
if (metrics) {
|
||||||
body.metrics = metrics;
|
body.metrics = metrics;
|
||||||
}
|
}
|
||||||
@@ -55,7 +60,7 @@ export function AiAnalysisCard({
|
|||||||
setErrorMsg('分析请求失败');
|
setErrorMsg('分析请求失败');
|
||||||
setState('error');
|
setState('error');
|
||||||
}
|
}
|
||||||
}, [analysisType, sourceRef, metrics]);
|
}, [analysisType, sourceRef, metrics, taskId]);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
setState('idle');
|
setState('idle');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||||
import { dayjs } from '../../utils/dayjs';
|
import { dayjs } from '../../utils/dayjs';
|
||||||
import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp';
|
import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp';
|
||||||
@@ -26,6 +26,7 @@ import { formatDate, formatDateTime } from '../../utils/format';
|
|||||||
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
import { usePaginatedData } from '../../hooks/usePaginatedData';
|
||||||
import { useApiRequest } from '../../hooks/useApiRequest';
|
import { useApiRequest } from '../../hooks/useApiRequest';
|
||||||
import { useDictionary } from '../../hooks/useDictionary';
|
import { useDictionary } from '../../hooks/useDictionary';
|
||||||
|
import { AiAnalysisCard } from '../../components/ai/AiAnalysisCard';
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ value: 'pending', label: '待处理' },
|
{ value: 'pending', label: '待处理' },
|
||||||
@@ -117,6 +118,9 @@ export default function FollowUpTaskList() {
|
|||||||
const [assignForm] = Form.useForm<AssignFormValues>();
|
const [assignForm] = Form.useForm<AssignFormValues>();
|
||||||
const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null);
|
const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null);
|
||||||
|
|
||||||
|
// AI summary state
|
||||||
|
const [aiSummaryTaskId, setAiSummaryTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
const handleTableChange = (pagination: TablePaginationConfig) => {
|
const handleTableChange = (pagination: TablePaginationConfig) => {
|
||||||
refresh(pagination.current ?? 1);
|
refresh(pagination.current ?? 1);
|
||||||
@@ -280,7 +284,7 @@ export default function FollowUpTaskList() {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 220,
|
width: 280,
|
||||||
render: (_: unknown, record: FollowUpTask) => (
|
render: (_: unknown, record: FollowUpTask) => (
|
||||||
<AuthButton code="health.follow-up.manage">
|
<AuthButton code="health.follow-up.manage">
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
@@ -292,6 +296,14 @@ export default function FollowUpTaskList() {
|
|||||||
>
|
>
|
||||||
填写记录
|
填写记录
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<ThunderboltOutlined />}
|
||||||
|
onClick={() => setAiSummaryTaskId(aiSummaryTaskId === record.id ? null : record.id)}
|
||||||
|
>
|
||||||
|
AI 小结
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -394,6 +406,18 @@ export default function FollowUpTaskList() {
|
|||||||
scroll={{ x: 980 }}
|
scroll={{ x: 980 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* AI 随访小结 */}
|
||||||
|
{aiSummaryTaskId && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<AiAnalysisCard
|
||||||
|
analysisType="follow-up-summary"
|
||||||
|
sourceRef={aiSummaryTaskId}
|
||||||
|
taskId={aiSummaryTaskId}
|
||||||
|
triggerLabel="AI 生成随访小结"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create Task Modal */}
|
{/* Create Task Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
title="新建随访任务"
|
title="新建随访任务"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub enum AnalysisType {
|
|||||||
Trends,
|
Trends,
|
||||||
CheckupPlan,
|
CheckupPlan,
|
||||||
ReportSummary,
|
ReportSummary,
|
||||||
|
FollowUpSummary,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnalysisType {
|
impl AnalysisType {
|
||||||
@@ -28,6 +29,7 @@ impl AnalysisType {
|
|||||||
Self::Trends => "trend",
|
Self::Trends => "trend",
|
||||||
Self::CheckupPlan => "checkup_plan",
|
Self::CheckupPlan => "checkup_plan",
|
||||||
Self::ReportSummary => "report_summary",
|
Self::ReportSummary => "report_summary",
|
||||||
|
Self::FollowUpSummary => "follow_up_summary",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ impl AnalysisType {
|
|||||||
Self::Trends => "health_trend_analysis",
|
Self::Trends => "health_trend_analysis",
|
||||||
Self::CheckupPlan => "personalized_checkup_plan",
|
Self::CheckupPlan => "personalized_checkup_plan",
|
||||||
Self::ReportSummary => "report_summary_generation",
|
Self::ReportSummary => "report_summary_generation",
|
||||||
|
Self::FollowUpSummary => "follow_up_summary_generation",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,6 +164,7 @@ mod tests {
|
|||||||
assert_eq!(AnalysisType::Trends.as_str(), "trend");
|
assert_eq!(AnalysisType::Trends.as_str(), "trend");
|
||||||
assert_eq!(AnalysisType::CheckupPlan.as_str(), "checkup_plan");
|
assert_eq!(AnalysisType::CheckupPlan.as_str(), "checkup_plan");
|
||||||
assert_eq!(AnalysisType::ReportSummary.as_str(), "report_summary");
|
assert_eq!(AnalysisType::ReportSummary.as_str(), "report_summary");
|
||||||
|
assert_eq!(AnalysisType::FollowUpSummary.as_str(), "follow_up_summary");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- AnalysisType::prompt_name ----
|
// ---- AnalysisType::prompt_name ----
|
||||||
@@ -180,6 +184,10 @@ mod tests {
|
|||||||
AnalysisType::ReportSummary.prompt_name(),
|
AnalysisType::ReportSummary.prompt_name(),
|
||||||
"report_summary_generation"
|
"report_summary_generation"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
AnalysisType::FollowUpSummary.prompt_name(),
|
||||||
|
"follow_up_summary_generation"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- AnalysisType serde round-trip ----
|
// ---- AnalysisType serde round-trip ----
|
||||||
@@ -191,6 +199,7 @@ mod tests {
|
|||||||
AnalysisType::Trends,
|
AnalysisType::Trends,
|
||||||
AnalysisType::CheckupPlan,
|
AnalysisType::CheckupPlan,
|
||||||
AnalysisType::ReportSummary,
|
AnalysisType::ReportSummary,
|
||||||
|
AnalysisType::FollowUpSummary,
|
||||||
];
|
];
|
||||||
for t in types {
|
for t in types {
|
||||||
let json = serde_json::to_string(&t).unwrap();
|
let json = serde_json::to_string(&t).unwrap();
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ pub struct AnalyzeBody {
|
|||||||
pub report_id: Option<uuid::Uuid>,
|
pub report_id: Option<uuid::Uuid>,
|
||||||
pub patient_id: Option<uuid::Uuid>,
|
pub patient_id: Option<uuid::Uuid>,
|
||||||
pub metrics: Option<Vec<String>>,
|
pub metrics: Option<Vec<String>>,
|
||||||
|
pub source_id: Option<uuid::Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// === SSE 分析端点 ===
|
// === SSE 分析端点 ===
|
||||||
@@ -378,6 +379,83 @@ where
|
|||||||
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post,
|
||||||
|
path = "/ai/analyze/follow-up-summary",
|
||||||
|
request_body = AnalyzeBody,
|
||||||
|
responses((status = 200, description = "SSE 随访小结生成流")),
|
||||||
|
tag = "AI 分析",
|
||||||
|
security(("bearer_auth" = [])),
|
||||||
|
)]
|
||||||
|
pub async fn stream_follow_up_summary<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(body): Json<AnalyzeBody>,
|
||||||
|
) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "ai.analysis.manage")?;
|
||||||
|
let task_id = body
|
||||||
|
.source_id
|
||||||
|
.ok_or_else(|| erp_core::error::AppError::Validation("source_id (task_id) 必填".into()))?;
|
||||||
|
|
||||||
|
let data = state
|
||||||
|
.health_provider
|
||||||
|
.get_follow_up_summary_data(ctx.tenant_id, task_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if data.records.is_empty() {
|
||||||
|
return Err(erp_core::error::AppError::Validation(
|
||||||
|
"该随访任务尚无随访记录,无法生成小结。请先填写至少一条随访记录。".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized_data = state.analysis.sanitizer.sanitize_follow_up_data(&data)?;
|
||||||
|
|
||||||
|
let prompt = state
|
||||||
|
.prompt
|
||||||
|
.get_active_prompt(ctx.tenant_id, "follow_up_summary_generation")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let model_config = &prompt.model_config;
|
||||||
|
let (model, temperature, max_tokens) =
|
||||||
|
resolve_model_config(model_config, ctx.tenant_id, &state.db).await;
|
||||||
|
|
||||||
|
let (stream, analysis_id, _) = state
|
||||||
|
.analysis
|
||||||
|
.stream_analyze(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
data.patient_id,
|
||||||
|
AnalysisType::FollowUpSummary,
|
||||||
|
task_id.to_string(),
|
||||||
|
prompt.system_prompt,
|
||||||
|
prompt.user_prompt_template,
|
||||||
|
sanitized_data,
|
||||||
|
model,
|
||||||
|
temperature,
|
||||||
|
max_tokens,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let analysis_id_clone = analysis_id;
|
||||||
|
let state_clone = state.clone();
|
||||||
|
let patient_id_clone = data.patient_id;
|
||||||
|
|
||||||
|
let sse_stream = build_sse_stream(
|
||||||
|
stream,
|
||||||
|
analysis_id_clone,
|
||||||
|
state_clone,
|
||||||
|
"follow_up_summary",
|
||||||
|
ctx.tenant_id,
|
||||||
|
patient_id_clone,
|
||||||
|
ctx.user_id,
|
||||||
|
);
|
||||||
|
Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default()))
|
||||||
|
}
|
||||||
|
|
||||||
// === 分析历史 ===
|
// === 分析历史 ===
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||||
|
|||||||
@@ -426,6 +426,10 @@ impl AiModule {
|
|||||||
"/ai/analyze/report-summary",
|
"/ai/analyze/report-summary",
|
||||||
axum::routing::post(crate::handler::stream_report_summary),
|
axum::routing::post(crate::handler::stream_report_summary),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/ai/analyze/follow-up-summary",
|
||||||
|
axum::routing::post(crate::handler::stream_follow_up_summary),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/ai/analysis/history",
|
"/ai/analysis/history",
|
||||||
axum::routing::get(crate::handler::list_analysis),
|
axum::routing::get(crate::handler::list_analysis),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use erp_core::health_provider::{
|
use erp_core::health_provider::{
|
||||||
HealthReportDto, LabReportDto, PatientSummaryDto, TrendAnalysisDto, VitalSignDto,
|
FollowUpSummaryDataDto, HealthReportDto, LabReportDto, PatientSummaryDto, TrendAnalysisDto,
|
||||||
|
VitalSignDto,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -56,6 +57,13 @@ impl SanitizationService {
|
|||||||
Ok(sanitized)
|
Ok(sanitized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sanitize_follow_up_data(&self, data: &FollowUpSummaryDataDto) -> AiResult<Value> {
|
||||||
|
let sanitized = serde_json::to_value(data)
|
||||||
|
.map_err(|e| AiError::SanitizationError(format!("序列化失败: {e}")))?;
|
||||||
|
self.verify_no_pii(&sanitized)?;
|
||||||
|
Ok(sanitized)
|
||||||
|
}
|
||||||
|
|
||||||
/// 二次验证: 确保没有意外泄漏的 PII
|
/// 二次验证: 确保没有意外泄漏的 PII
|
||||||
fn verify_no_pii(&self, value: &Value) -> AiResult<()> {
|
fn verify_no_pii(&self, value: &Value) -> AiResult<()> {
|
||||||
let pii_keys = [
|
let pii_keys = [
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ use erp_ai::dto::{AgentGenerateResponse, ChatMessage, ChatMessageRole, ToolCall,
|
|||||||
use erp_ai::error::AiResult;
|
use erp_ai::error::AiResult;
|
||||||
use erp_ai::provider::AiProvider;
|
use erp_ai::provider::AiProvider;
|
||||||
use erp_core::health_provider::{
|
use erp_core::health_provider::{
|
||||||
AppointmentSummaryDto, HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto,
|
AppointmentSummaryDto, FollowUpSummaryDataDto, HealthDataProvider, HealthReportDto, LabItemDto,
|
||||||
LabReportListItemDto, MedicationSummaryDto, PatientSummaryDto, TimeRange, TrendAnalysisDto,
|
LabReportDto, LabReportListItemDto, MedicationSummaryDto, PatientSummaryDto, TimeRange,
|
||||||
VitalSignDto,
|
TrendAnalysisDto, VitalSignDto,
|
||||||
};
|
};
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -195,6 +195,13 @@ impl HealthDataProvider for MockHealthDataProvider {
|
|||||||
) -> erp_core::error::AppResult<Vec<LabReportListItemDto>> {
|
) -> erp_core::error::AppResult<Vec<LabReportListItemDto>> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
|
async fn get_follow_up_summary_data(
|
||||||
|
&self,
|
||||||
|
_tenant_id: Uuid,
|
||||||
|
_task_id: Uuid,
|
||||||
|
) -> erp_core::error::AppResult<FollowUpSummaryDataDto> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 测试 ===
|
// === 测试 ===
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ pub trait HealthDataProvider: Send + Sync {
|
|||||||
patient_id: Uuid,
|
patient_id: Uuid,
|
||||||
limit: u64,
|
limit: u64,
|
||||||
) -> AppResult<Vec<LabReportListItemDto>>;
|
) -> AppResult<Vec<LabReportListItemDto>>;
|
||||||
|
|
||||||
|
/// 获取随访摘要数据(任务 + 已有记录,用于 AI 生成小结)
|
||||||
|
async fn get_follow_up_summary_data(
|
||||||
|
&self,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
task_id: Uuid,
|
||||||
|
) -> AppResult<FollowUpSummaryDataDto>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === DTO 定义 ===
|
// === DTO 定义 ===
|
||||||
@@ -200,3 +207,22 @@ pub struct LabReportListItemDto {
|
|||||||
pub report_date: String,
|
pub report_date: String,
|
||||||
pub abnormal_count: usize,
|
pub abnormal_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FollowUpSummaryDataDto {
|
||||||
|
pub task_id: Uuid,
|
||||||
|
pub patient_id: Uuid,
|
||||||
|
pub follow_up_type: String,
|
||||||
|
pub planned_date: String,
|
||||||
|
pub task_status: String,
|
||||||
|
pub records: Vec<FollowUpRecordDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FollowUpRecordDto {
|
||||||
|
pub executed_date: String,
|
||||||
|
pub result: String,
|
||||||
|
pub patient_condition: Option<String>,
|
||||||
|
pub medical_advice: Option<String>,
|
||||||
|
pub next_follow_up_date: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ use chrono::Datelike;
|
|||||||
use erp_core::crypto::{self as pii, PiiCrypto};
|
use erp_core::crypto::{self as pii, PiiCrypto};
|
||||||
use erp_core::error::{AppError, AppResult};
|
use erp_core::error::{AppError, AppResult};
|
||||||
use erp_core::health_provider::{
|
use erp_core::health_provider::{
|
||||||
AnomalyInfo, AppointmentSummaryDto, HealthDataProvider, HealthReportDto, LabItemDto,
|
AnomalyInfo, AppointmentSummaryDto, FollowUpRecordDto, FollowUpSummaryDataDto,
|
||||||
LabReportDto, LabReportListItemDto, MedicationSummaryDto, MetricTrendAnalysis,
|
HealthDataProvider, HealthReportDto, LabItemDto, LabReportDto, LabReportListItemDto,
|
||||||
PatientSummaryDto, RegressionStats, ReportSectionDto, TimeRange, TrendAnalysisDto,
|
MedicationSummaryDto, MetricTrendAnalysis, PatientSummaryDto, RegressionStats,
|
||||||
TrendDirection, VitalSignDto,
|
ReportSectionDto, TimeRange, TrendAnalysisDto, TrendDirection, VitalSignDto,
|
||||||
};
|
};
|
||||||
use num_traits::ToPrimitive;
|
use num_traits::ToPrimitive;
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::entity::{
|
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 {
|
pub struct HealthDataProviderImpl {
|
||||||
@@ -668,4 +669,50 @@ impl HealthDataProvider for HealthDataProviderImpl {
|
|||||||
|
|
||||||
Ok(result)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user