Compare commits

...

11 Commits

Author SHA1 Message Date
iven
47df2e2aa6 perf(web): manualChunks 拆分 heavy deps + lazy ProcessDesigner/ProcessViewer
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- vite.config.ts 添加 vendor-charts/plots/graphs, vendor-flow, vendor-editor 独立 chunk
- vendor-antd 从 3000kB 降至 1532kB,charts 独立 1459kB
- ProcessDesigner/ProcessViewer 改为 React.lazy 按需加载
- 移除 PluginGraphPage 遗留的 animFrameRef 未使用变量
2026-04-27 10:11:12 +08:00
iven
af44476c0f perf(web): PluginGraphPage 替换持续 rAF 循环为按需重绘
移除持续 requestAnimationFrame 循环,改为数据变更 useEffect 触发
单次重绘 + ResizeObserver 监听容器变化,静态页面 CPU 占用大幅降低。
2026-04-27 09:58:51 +08:00
iven
1c7184b6bc perf(web): PluginCRUDPage columns 包裹 useMemo 避免重渲染
columns 依赖 fields/resolvedLabels/labelMeta,搜索输入时不再重建列定义。
2026-04-27 09:57:41 +08:00
iven
0929825ae7 perf(health): alert_engine 批量预加载 + 内存匹配替代逐规则DB查询
批量查询 cooldown 期间所有 alerts 和最近 hourly 记录,
在内存中完成 cooldown 检查和规则匹配。
N规则评估从 2N+ 次查询降为 2 次批量查询。
2026-04-27 09:55:39 +08:00
iven
0a387c189a perf(health): get_health_summary 4次串行查询改为 tokio::join! 并行
总延迟从 sum(4次查询) 降为 max(4次查询),预估延迟降低约75%。
2026-04-27 09:52:31 +08:00
iven
04c5f3c0d5 perf(health): stats_service 合并 COUNT 为 GROUP BY + 宏化 compute_avg_field
get_follow_up_statistics: 4次独立COUNT合并为1次GROUP BY GROUPING SETS。
compute_avg_field: format! 动态拼接改为宏生成静态SQL,利用PG prepared statement缓存。
2026-04-27 09:50:10 +08:00
iven
f934ca0eaf perf(web): ConsultationList/FollowUpTaskList 移除 N+1 nameCache
后端已内联 patient_name/doctor_name,前端移除逐条查询。
Session/FollowUpTask 接口添加 name 可选字段。
FollowUpTaskList 保留 assignee 的 getUser 查询(users 表未内联)。
2026-04-27 09:47:37 +08:00
iven
c6856370c6 perf(web): AppointmentList 移除 nameCache N+1 请求
后端已内联 patient_name/doctor_name,前端移除逐条查询
patientApi/doctorApi 的 nameCache 逻辑,列表加载降为 O(1) 请求。
2026-04-27 09:41:47 +08:00
iven
4a5dbaeaeb feat(health): consultation/follow_up 列表 API 内联 patient_name/doctor_name
consultation session list 添加 patient_name/doctor_name,
follow_up task list 添加 patient_name,批量查询消除 N+1。
DTO 新增 Option 字段,向后兼容。
2026-04-27 09:39:46 +08:00
iven
432f6e3554 feat(health): appointment list API 内联 patient_name/doctor_name
列表查询后批量获取 patient 和 doctor 名称,消除前端 N+1 请求。
DTO 新增 patient_name/doctor_name Option 字段,向后兼容。
2026-04-27 09:34:04 +08:00
iven
c09f6ecdc8 perf(health): upsert_hourly_aggregates 批量化 — 批量查询+insert_many
将逐组查询+更新/插入改为一次批量查询所有已存在记录,
分为"新增"和"更新"两组,新增用 insert_many() 一次性插入。
查询次数从 N 降为 1+更新数。
2026-04-27 09:29:55 +08:00
20 changed files with 387 additions and 357 deletions

View File

@@ -6,6 +6,8 @@ export interface Session {
id: string;
patient_id: string;
doctor_id?: string;
patient_name?: string;
doctor_name?: string;
consultation_type: string;
status: string;
last_message_at?: string;

View File

@@ -6,6 +6,7 @@ export interface FollowUpTask {
id: string;
patient_id: string;
assigned_to?: string;
patient_name?: string;
follow_up_type: string;
planned_date: string;
status: string;

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
Table,
@@ -305,8 +305,8 @@ export default function PluginCRUDPage({
}
};
// 动态生成列
const columns = [
// 动态生成列memo 化避免输入搜索时重建)
const columns = useMemo(() => [
...fields.slice(0, 5).map((f) => ({
title: f.display_name || f.name,
dataIndex: f.name,
@@ -315,7 +315,6 @@ export default function PluginCRUDPage({
sorter: f.sortable ? true : undefined,
render: (val: unknown) => {
if (typeof val === 'boolean') return val ? <Tag color="green"></Tag> : <Tag></Tag>;
// 引用字段 → 显示解析后的标签
if (f.ref_entity) {
const uuid = String(val ?? '');
if (!uuid || uuid === '-') return '-';
@@ -366,7 +365,7 @@ export default function PluginCRUDPage({
</Space>
),
},
];
], [fields, resolvedLabels, labelMeta, hasDetailPage, handleDelete]);
// 动态生成表单字段
const renderFormField = (field: PluginFieldSchema) => {

View File

@@ -59,7 +59,6 @@ export function PluginGraphPage() {
const { token } = theme.useToken();
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animFrameRef = useRef<number>(0);
const nodePositionsRef = useRef<Map<string, NodePosition>>(new Map());
const visibleNodesRef = useRef<GraphNode[]>([]);
const visibleEdgesRef = useRef<GraphEdge[]>([]);
@@ -376,15 +375,22 @@ export function PluginGraphPage() {
}
}, [canvasSize, selectedCenter, hoverState, token]);
// ── Animation loop ──
// ── On-demand redraw: data changes + resize ──
// 数据变更时触发单次重绘
useEffect(() => {
const animate = () => {
drawGraph();
}, [drawGraph]);
// 容器大小变化时触发重绘
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
drawGraph();
animFrameRef.current = requestAnimationFrame(animate);
};
animFrameRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animFrameRef.current);
});
observer.observe(container);
return () => observer.disconnect();
}, [drawGraph]);
// ── Mouse interaction handlers ──

View File

@@ -22,8 +22,6 @@ import {
} from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import { appointmentApi, type Appointment, type CreateAppointmentReq } from '../../api/health/appointments';
import { patientApi } from '../../api/health/patients';
import { doctorApi } from '../../api/health/doctors';
import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect';
import { DoctorSelect } from './components/DoctorSelect';
@@ -87,7 +85,6 @@ export default function AppointmentList() {
// 患者选择状态(受控组件,不挂在 Form.Item 上)
const [selectedPatientId, setSelectedPatientId] = useState<string | undefined>(undefined);
const [selectedDoctorId, setSelectedDoctorId] = useState<string | undefined>(undefined);
const [nameCache, setNameCache] = useState<Record<string, string>>({});
// 排班校验
const [scheduleHint, setScheduleHint] = useState<string | null>(null);
@@ -103,27 +100,7 @@ export default function AppointmentList() {
status: statusFilter || undefined,
date: dateFilter ? dateFilter.format('YYYY-MM-DD') : undefined,
});
const items = result.data;
// 批量解析患者和医生名称(分别调用对应 API
const missingPatientIds = new Set<string>();
const missingDoctorIds = new Set<string>();
items.forEach((a) => {
if (a.patient_id && !nameCache[a.patient_id]) missingPatientIds.add(a.patient_id);
if (a.doctor_id && !nameCache[a.doctor_id]) missingDoctorIds.add(a.doctor_id);
});
const newCache: Record<string, string> = {};
await Promise.allSettled([
...Array.from(missingPatientIds).map(async (id) => {
try { const p = await patientApi.get(id); newCache[id] = p.name; } catch { /* skip */ }
}),
...Array.from(missingDoctorIds).map(async (id) => {
try { const d = await doctorApi.get(id); newCache[id] = d.name; } catch { /* skip */ }
}),
]);
if (Object.keys(newCache).length > 0) {
setNameCache((prev) => ({ ...prev, ...newCache }));
}
setData(items);
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载预约列表失败');
@@ -274,16 +251,15 @@ export default function AppointmentList() {
key: 'patient_name',
width: 100,
render: (_: unknown, record: Appointment) =>
record.patient_name ?? nameCache[record.patient_id] ?? record.patient_id.slice(0, 8),
record.patient_name ?? record.patient_id.slice(0, 8),
},
{
title: '医护',
dataIndex: 'doctor_name',
key: 'doctor_name',
width: 100,
render: (_: unknown, record: Appointment) => {
return record.doctor_name || nameCache[record.doctor_id ?? ''] || record.doctor_id?.slice(0, 8) || '-';
},
render: (_: unknown, record: Appointment) =>
record.doctor_name ?? record.doctor_id?.slice(0, 8) ?? '-',
},
{
title: '预约类型',

View File

@@ -13,8 +13,6 @@ import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { useNavigate } from 'react-router-dom';
import { consultationApi, type Session, type CreateSessionReq } from '../../api/health/consultations';
import { patientApi } from '../../api/health/patients';
import { doctorApi } from '../../api/health/doctors';
import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect';
import { DoctorSelect } from './components/DoctorSelect';
@@ -69,10 +67,6 @@ export default function ConsultationList() {
// Close session
const [closingId, setClosingId] = useState<string | null>(null);
// Label caches
const [patientLabels, setPatientLabels] = useState<Record<string, string>>({});
const [doctorLabels, setDoctorLabels] = useState<Record<string, string>>({});
const isDark = useThemeMode();
// --- Data fetching ---
@@ -82,48 +76,12 @@ export default function ConsultationList() {
const result = await consultationApi.listSessions(params);
setSessions(result.data);
setTotal(result.total);
// 批量解析患者名称
const patientIds = [...new Set(result.data.map((s) => s.patient_id))];
const missingPatientIds = patientIds.filter((id) => !patientLabels[id]);
if (missingPatientIds.length > 0) {
const newLabels: Record<string, string> = {};
await Promise.all(
missingPatientIds.map(async (id) => {
try {
const detail = await patientApi.get(id);
newLabels[id] = detail.name;
} catch {
newLabels[id] = id.slice(0, 8);
}
}),
);
setPatientLabels((prev) => ({ ...prev, ...newLabels }));
}
// 批量解析医生名称
const doctorIds = [...new Set(result.data.map((s) => s.doctor_id).filter(Boolean))] as string[];
const missingDoctorIds = doctorIds.filter((id) => !doctorLabels[id]);
if (missingDoctorIds.length > 0) {
const newLabels: Record<string, string> = {};
await Promise.all(
missingDoctorIds.map(async (id) => {
try {
const detail = await doctorApi.get(id);
newLabels[id] = detail.name;
} catch {
newLabels[id] = id.slice(0, 8);
}
}),
);
setDoctorLabels((prev) => ({ ...prev, ...newLabels }));
}
} catch {
message.error('加载咨询列表失败');
} finally {
setLoading(false);
}
}, [patientLabels, doctorLabels]);
}, []);
useEffect(() => {
fetchSessions(query);
@@ -142,14 +100,6 @@ export default function ConsultationList() {
}));
};
const handlePatientLabel = (id: string, label: string) => {
setPatientLabels((prev) => ({ ...prev, [id]: label }));
};
const handleDoctorLabel = (id: string, label: string) => {
setDoctorLabels((prev) => ({ ...prev, [id]: label }));
};
// Create session
const handleCreate = async () => {
try {
@@ -195,18 +145,19 @@ export default function ConsultationList() {
const columns: ColumnsType<Session> = [
{
title: '患者',
dataIndex: 'patient_id',
key: 'patient_id',
dataIndex: 'patient_name',
key: 'patient_name',
width: 140,
render: (id: string) => patientLabels[id] || id.slice(0, 8),
render: (_: unknown, record: Session) =>
record.patient_name ?? record.patient_id.slice(0, 8),
},
{
title: '医护',
dataIndex: 'doctor_id',
key: 'doctor_id',
dataIndex: 'doctor_name',
key: 'doctor_name',
width: 140,
render: (id: string | undefined) =>
id ? doctorLabels[id] || id.slice(0, 8) : '-',
render: (_: unknown, record: Session) =>
record.doctor_name ?? record.doctor_id?.slice(0, 8) ?? '-',
},
{
title: '咨询类型',
@@ -383,14 +334,10 @@ export default function ConsultationList() {
label="患者"
rules={[{ required: true, message: '请选择患者' }]}
>
<PatientSelect
onChange={(_val, label) => handlePatientLabel(_val, label)}
/>
<PatientSelect />
</Form.Item>
<Form.Item name="doctor_id" label="医护">
<DoctorSelect
onChange={(_val, label) => handleDoctorLabel(_val, label)}
/>
<DoctorSelect />
</Form.Item>
<Form.Item name="consultation_type" label="咨询类型">
<Select

View File

@@ -15,7 +15,6 @@ import { PlusOutlined, EditOutlined, SwapOutlined, DeleteOutlined } from '@ant-d
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { followUpApi, type FollowUpTask, type CreateFollowUpTaskReq, type UpdateFollowUpTaskReq } from '../../api/health/followUp';
import { patientApi } from '../../api/health/patients';
import { getUser } from '../../api/users';
import { StatusTag } from './components/StatusTag';
import { PatientSelect } from './components/PatientSelect';
@@ -93,8 +92,7 @@ export default function FollowUpTaskList() {
const [assignForm] = Form.useForm<AssignFormValues>();
const [assignTask, setAssignTask] = useState<FollowUpTask | null>(null);
// Patient/doctor label cache for display
const [patientLabels, setPatientLabels] = useState<Record<string, string>>({});
// Doctor label cache (for assignee display from users table)
const [doctorLabels, setDoctorLabels] = useState<Record<string, string>>({});
const isDark = useThemeMode();
@@ -107,41 +105,29 @@ export default function FollowUpTaskList() {
setTasks(result.data);
setTotal(result.total);
// Batch resolve patient names
const patientIds = [...new Set(result.data.map((t: FollowUpTask) => t.patient_id).filter(Boolean))];
const newLabels: Record<string, string> = {};
await Promise.allSettled(
patientIds.map(async (id: string) => {
try {
const p = await patientApi.get(id);
newLabels[id] = p.name;
} catch { /* skip */ }
}),
);
if (Object.keys(newLabels).length > 0) {
setPatientLabels((prev) => ({ ...prev, ...newLabels }));
}
// Batch resolve assignee names
// Batch resolve assignee names (from users table, not inlined by backend)
const assigneeIds = [...new Set(result.data.map((t: FollowUpTask) => t.assigned_to).filter((x): x is string => !!x))];
const newDoctorLabels: Record<string, string> = {};
await Promise.allSettled(
assigneeIds.map(async (id) => {
try {
const u = await getUser(id);
newDoctorLabels[id] = u.display_name || u.username;
} catch { /* skip */ }
}),
);
if (Object.keys(newDoctorLabels).length > 0) {
setDoctorLabels((prev) => ({ ...prev, ...newDoctorLabels }));
const missingIds = assigneeIds.filter((id) => !doctorLabels[id]);
if (missingIds.length > 0) {
const newDoctorLabels: Record<string, string> = {};
await Promise.allSettled(
missingIds.map(async (id) => {
try {
const u = await getUser(id);
newDoctorLabels[id] = u.display_name || u.username;
} catch { /* skip */ }
}),
);
if (Object.keys(newDoctorLabels).length > 0) {
setDoctorLabels((prev) => ({ ...prev, ...newDoctorLabels }));
}
}
} catch {
message.error('加载随访任务失败');
} finally {
setLoading(false);
}
}, []);
}, [doctorLabels]);
useEffect(() => {
fetchTasks(query);
@@ -260,10 +246,6 @@ export default function FollowUpTaskList() {
};
// Store labels from selects
const handlePatientLabel = (id: string, label: string) => {
setPatientLabels((prev) => ({ ...prev, [id]: label }));
};
const handleDoctorLabel = (id: string, label: string) => {
setDoctorLabels((prev) => ({ ...prev, [id]: label }));
};
@@ -272,10 +254,11 @@ export default function FollowUpTaskList() {
const columns: ColumnsType<FollowUpTask> = [
{
title: '患者',
dataIndex: 'patient_id',
key: 'patient_id',
dataIndex: 'patient_name',
key: 'patient_name',
width: 140,
render: (id: string) => patientLabels[id] || id.slice(0, 8),
render: (_: unknown, record: FollowUpTask) =>
record.patient_name ?? record.patient_id.slice(0, 8),
},
{
title: '随访类型',
@@ -445,9 +428,7 @@ export default function FollowUpTaskList() {
label="患者"
rules={[{ required: true, message: '请选择患者' }]}
>
<PatientSelect
onChange={(_val, label) => handlePatientLabel(_val, label)}
/>
<PatientSelect />
</Form.Item>
<Form.Item
name="follow_up_type"

View File

@@ -1,5 +1,5 @@
import { useEffect, useCallback, useState, useRef } from 'react';
import { Button, message, Modal, Table, Tag } from 'antd';
import { useEffect, useCallback, useState, useRef, lazy, Suspense } from 'react';
import { Button, message, Modal, Spin, Table, Tag } from 'antd';
import { EyeOutlined, PauseCircleOutlined, PlayCircleOutlined, StopOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -10,7 +10,8 @@ import {
type ProcessInstanceInfo,
} from '../../api/workflowInstances';
import { getProcessDefinition, type NodeDef, type EdgeDef } from '../../api/workflowDefinitions';
import ProcessViewer from './ProcessViewer';
const ProcessViewer = lazy(() => import('./ProcessViewer'));
import { useThemeMode } from '../../hooks/useThemeMode';
const statusStyles: Record<string, { bg: string; color: string; text: string }> = {
@@ -289,7 +290,9 @@ export default function InstanceMonitor() {
width={720}
loading={viewerLoading}
>
<ProcessViewer nodes={viewerNodes} edges={viewerEdges} activeNodeIds={activeNodeIds} />
<Suspense fallback={<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>}>
<ProcessViewer nodes={viewerNodes} edges={viewerEdges} activeNodeIds={activeNodeIds} />
</Suspense>
</Modal>
</>
);

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback } from 'react';
import { Button, message, Modal, Space, Table, Tag } from 'antd';
import { useEffect, useState, useCallback, lazy, Suspense } from 'react';
import { Button, message, Modal, Space, Spin, Table, Tag } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -10,7 +10,8 @@ import {
type ProcessDefinitionInfo,
type CreateProcessDefinitionRequest,
} from '../../api/workflowDefinitions';
import ProcessDesigner from './ProcessDesigner';
const ProcessDesigner = lazy(() => import('./ProcessDesigner'));
import { useThemeMode } from '../../hooks/useThemeMode';
const statusColors: Record<string, { bg: string; color: string; text: string }> = {
@@ -190,10 +191,12 @@ export default function ProcessDefinitions() {
width={1200}
destroyOnClose
>
<ProcessDesigner
definitionId={editingId}
onSave={handleSave}
/>
<Suspense fallback={<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>}>
<ProcessDesigner
definitionId={editingId}
onSave={handleSave}
/>
</Suspense>
</Modal>
</>
);

View File

@@ -26,6 +26,15 @@ export default defineConfig({
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/") || id.includes("node_modules/react-router-dom")) {
return "vendor-react";
}
if (id.includes("node_modules/@ant-design/charts") || id.includes("node_modules/@ant-design/plots") || id.includes("node_modules/@ant-design/graphs")) {
return "vendor-charts";
}
if (id.includes("node_modules/@xyflow/react")) {
return "vendor-flow";
}
if (id.includes("node_modules/@wangeditor/") || id.includes("node_modules/wangeditor/")) {
return "vendor-editor";
}
if (id.includes("node_modules/antd") || id.includes("node_modules/@ant-design")) {
return "vendor-antd";
}

View File

@@ -38,6 +38,8 @@ pub struct AppointmentResp {
pub id: Uuid,
pub patient_id: Uuid,
pub doctor_id: Option<Uuid>,
pub patient_name: Option<String>,
pub doctor_name: Option<String>,
pub appointment_type: String,
pub appointment_date: NaiveDate,
pub start_time: NaiveTime,

View File

@@ -8,6 +8,8 @@ pub struct SessionResp {
pub id: Uuid,
pub patient_id: Uuid,
pub doctor_id: Option<Uuid>,
pub patient_name: Option<String>,
pub doctor_name: Option<String>,
pub consultation_type: String,
pub status: String,
pub last_message_at: Option<chrono::DateTime<chrono::Utc>>,

View File

@@ -49,6 +49,7 @@ pub struct FollowUpTaskResp {
pub id: Uuid,
pub patient_id: Uuid,
pub assigned_to: Option<Uuid>,
pub patient_name: Option<String>,
pub follow_up_type: String,
pub planned_date: NaiveDate,
pub status: String,

View File

@@ -2,6 +2,7 @@ use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use serde_json::json;
use std::collections::HashSet;
use uuid::Uuid;
use crate::entity::{alert_rules, alerts, vital_signs_hourly};
@@ -23,10 +24,37 @@ pub async fn evaluate_rules(
.all(&state.db)
.await?;
if rules.is_empty() {
return Ok(Vec::new());
}
// 批量查询 cooldown 期间的 alerts
let max_cooldown: i64 = rules.iter().map(|r| r.cooldown_minutes as i64).max().unwrap_or(60);
let cooldown_start = Utc::now() - chrono::Duration::minutes(max_cooldown);
let recent_alerts = alerts::Entity::find()
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::PatientId.eq(patient_id))
.filter(alerts::Column::CreatedAt.gt(cooldown_start))
.filter(alerts::Column::DeletedAt.is_null())
.all(&state.db)
.await?;
let cooldown_set: HashSet<Uuid> = recent_alerts.iter().map(|a| a.rule_id).collect();
// 批量查询最近的 hourly 记录(最多取最近 168 小时用于 trend 判断)
let hourly_records = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(device_type))
.filter(vital_signs_hourly::Column::HourStart.gt(Utc::now() - chrono::Duration::hours(168)))
.order_by_desc(vital_signs_hourly::Column::HourStart)
.all(&state.db)
.await?;
let mut triggered_alerts = Vec::new();
for rule in rules {
if is_in_cooldown(&state.db, tenant_id, patient_id, rule.id, rule.cooldown_minutes).await? {
// 检查 cooldown使用预先查询的集合
if cooldown_set.contains(&rule.id) {
continue;
}
@@ -34,15 +62,9 @@ pub async fn evaluate_rules(
let condition_type = rule.condition_type.as_str();
let is_triggered = match condition_type {
"single_threshold" => evaluate_single_threshold(
&state.db, tenant_id, patient_id, device_type, params
).await?,
"consecutive" => evaluate_consecutive(
&state.db, tenant_id, patient_id, device_type, params
).await?,
"trend" => evaluate_trend(
&state.db, tenant_id, patient_id, device_type, params
).await?,
"single_threshold" => evaluate_single_threshold_in_memory(&hourly_records, params),
"consecutive" => evaluate_consecutive_in_memory(&hourly_records, params),
"trend" => evaluate_trend_in_memory(&hourly_records, params),
_ => false,
};
@@ -57,90 +79,49 @@ pub async fn evaluate_rules(
Ok(triggered_alerts)
}
async fn is_in_cooldown(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
rule_id: Uuid,
cooldown_minutes: i32,
) -> HealthResult<bool> {
let cooldown_start = Utc::now() - chrono::Duration::minutes(cooldown_minutes as i64);
let recent = alerts::Entity::find()
.filter(alerts::Column::TenantId.eq(tenant_id))
.filter(alerts::Column::PatientId.eq(patient_id))
.filter(alerts::Column::RuleId.eq(rule_id))
.filter(alerts::Column::CreatedAt.gt(cooldown_start))
.filter(alerts::Column::DeletedAt.is_null())
.one(db)
.await?;
Ok(recent.is_some())
}
async fn evaluate_single_threshold(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
fn evaluate_single_threshold_in_memory(
records: &[vital_signs_hourly::Model],
params: &serde_json::Value,
) -> HealthResult<bool> {
) -> bool {
let direction = params["direction"].as_str().unwrap_or("above");
let threshold = params["value"].as_f64().unwrap_or(f64::MAX);
let latest = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(device_type))
.order_by_desc(vital_signs_hourly::Column::HourStart)
.one(db)
.await?;
match latest {
// records 已按 HourStart DESC 排序,第一条即最新
match records.first() {
Some(record) => {
let val = record.avg_val;
Ok(match direction {
match direction {
"above" => val > threshold,
"below" => val < threshold,
_ => false,
})
}
}
None => Ok(false),
None => false,
}
}
async fn evaluate_consecutive(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
fn evaluate_consecutive_in_memory(
records: &[vital_signs_hourly::Model],
params: &serde_json::Value,
) -> HealthResult<bool> {
let count = params["count"].as_u64().unwrap_or(3) as u64;
) -> bool {
let count = params["count"].as_u64().unwrap_or(3) as usize;
let direction = params["direction"].as_str().unwrap_or("above");
let threshold = params["value"].as_f64().unwrap_or(f64::MAX);
let window_hours = params["window_hours"].as_i64();
use sea_orm::QueryOrder;
let mut query = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(device_type))
.order_by_desc(vital_signs_hourly::Column::HourStart);
// records 已按 HourStart DESC 排序
let cutoff = window_hours.map(|h| Utc::now() - chrono::Duration::hours(h));
let recent: Vec<_> = records
.iter()
.take_while(|r| cutoff.map_or(true, |c| r.hour_start > c))
.take(count)
.collect();
if let Some(hours) = window_hours {
let since = Utc::now() - chrono::Duration::hours(hours);
query = query.filter(vital_signs_hourly::Column::HourStart.gt(since));
if recent.len() < count {
return false;
}
let records: Vec<_> = query
.limit(count)
.all(db)
.await?;
if records.len() < count as usize {
return Ok(false);
}
let all_exceed = records.iter().all(|r| {
let all_exceed = recent.iter().all(|r| {
match direction {
"above" => r.avg_val > threshold,
"below" => r.avg_val < threshold,
@@ -148,45 +129,39 @@ async fn evaluate_consecutive(
}
});
Ok(all_exceed)
all_exceed
}
async fn evaluate_trend(
db: &DatabaseConnection,
tenant_id: Uuid,
patient_id: Uuid,
device_type: &str,
fn evaluate_trend_in_memory(
records: &[vital_signs_hourly::Model],
params: &serde_json::Value,
) -> HealthResult<bool> {
) -> bool {
let window_hours = params["window_hours"].as_i64().unwrap_or(168);
let delta_threshold = params["delta"].as_f64().unwrap_or(20.0);
let direction = params["direction"].as_str().unwrap_or("up");
let since = Utc::now() - chrono::Duration::hours(window_hours);
use sea_orm::QueryOrder;
let records: Vec<_> = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(device_type))
.filter(vital_signs_hourly::Column::HourStart.gt(since))
.order_by_asc(vital_signs_hourly::Column::HourStart)
.all(db)
.await?;
// records 已按 HourStart DESC 排序,需要按时间正序取首尾
let mut in_window: Vec<_> = records
.iter()
.filter(|r| r.hour_start > since)
.collect();
in_window.sort_by_key(|r| r.hour_start);
if records.len() < 2 {
return Ok(false);
if in_window.len() < 2 {
return false;
}
let first = records.first().unwrap().avg_val;
let last = records.last().unwrap().avg_val;
let first = in_window.first().unwrap().avg_val;
let last = in_window.last().unwrap().avg_val;
let actual_delta = last - first;
Ok(match direction {
match direction {
"up" => actual_delta > delta_threshold,
"down" => actual_delta < -delta_threshold,
_ => false,
})
}
}
async fn create_alert_and_notify(

View File

@@ -14,6 +14,7 @@ use erp_core::types::PaginatedResponse;
use crate::dto::appointment_dto::*;
use crate::entity::{appointment, doctor_profile, doctor_schedule, patient};
use crate::error::{HealthError, HealthResult};
use std::collections::{HashMap, HashSet};
use crate::service::validation::{
validate_appointment_status_transition, validate_appointment_type,
validate_period_type, validate_schedule_status,
@@ -54,9 +55,41 @@ pub async fn list_appointments(
.all(&state.db)
.await?;
// 批量查询 patient_name 和 doctor_name
let patient_ids: HashSet<Uuid> = models.iter().map(|m| m.patient_id).collect();
let doctor_ids: HashSet<Uuid> = models.iter().filter_map(|m| m.doctor_id).collect();
let patient_names: HashMap<Uuid, String> = if !patient_ids.is_empty() {
patient::Entity::find()
.filter(patient::Column::Id.is_in(patient_ids))
.filter(patient::Column::TenantId.eq(tenant_id))
.all(&state.db)
.await?
.into_iter()
.map(|p| (p.id, p.name))
.collect()
} else {
HashMap::new()
};
let doctor_names: HashMap<Uuid, String> = if !doctor_ids.is_empty() {
doctor_profile::Entity::find()
.filter(doctor_profile::Column::Id.is_in(doctor_ids))
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
.all(&state.db)
.await?
.into_iter()
.map(|d| (d.id, d.name))
.collect()
} else {
HashMap::new()
};
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,
patient_name: patient_names.get(&m.patient_id).cloned(),
doctor_name: m.doctor_id.and_then(|did| doctor_names.get(&did).cloned()),
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,
@@ -81,6 +114,7 @@ pub async fn get_appointment(
Ok(AppointmentResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
patient_name: None, doctor_name: None,
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,
@@ -189,6 +223,7 @@ pub async fn create_appointment(
Ok(AppointmentResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
patient_name: None, doctor_name: None,
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,
@@ -280,6 +315,7 @@ pub async fn update_appointment_status(
Ok(AppointmentResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
patient_name: None, doctor_name: None,
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,

View File

@@ -12,7 +12,7 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::consultation_dto::*;
use crate::entity::{consultation_message, consultation_session, patient};
use crate::entity::{consultation_message, consultation_session, doctor_profile, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::{validate_sender_role, validate_content_type, validate_consultation_type};
use crate::state::HealthState;
@@ -25,6 +25,7 @@ use erp_core::crypto as pii;
fn model_to_session_resp(m: consultation_session::Model) -> SessionResp {
SessionResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
patient_name: None, doctor_name: None,
consultation_type: m.consultation_type, status: m.status,
last_message_at: m.last_message_at,
unread_count_patient: m.unread_count_patient,
@@ -216,8 +217,43 @@ pub async fn export_sessions(
.all(&state.db)
.await?;
// 批量查询 patient_name 和 doctor_name
let patient_ids: std::collections::HashSet<Uuid> = models.iter().map(|m| m.patient_id).collect();
let doctor_ids: std::collections::HashSet<Uuid> = models.iter().filter_map(|m| m.doctor_id).collect();
let patient_names: std::collections::HashMap<Uuid, String> = if !patient_ids.is_empty() {
patient::Entity::find()
.filter(patient::Column::Id.is_in(patient_ids))
.filter(patient::Column::TenantId.eq(tenant_id))
.all(&state.db)
.await?
.into_iter()
.map(|p| (p.id, p.name))
.collect()
} else {
std::collections::HashMap::new()
};
let doctor_names: std::collections::HashMap<Uuid, String> = if !doctor_ids.is_empty() {
doctor_profile::Entity::find()
.filter(doctor_profile::Column::Id.is_in(doctor_ids))
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
.all(&state.db)
.await?
.into_iter()
.map(|d| (d.id, d.name))
.collect()
} else {
std::collections::HashMap::new()
};
let total_pages = total.div_ceil(limit.max(1));
let data = models.into_iter().map(model_to_session_resp).collect();
let data = models.into_iter().map(|m| {
let mut resp = model_to_session_resp(m.clone());
resp.patient_name = patient_names.get(&m.patient_id).cloned();
resp.doctor_name = m.doctor_id.and_then(|did| doctor_names.get(&did).cloned());
resp
}).collect();
Ok(PaginatedResponse { data, total, page: page_num, page_size: limit, total_pages })
}

View File

@@ -234,7 +234,6 @@ async fn upsert_hourly_aggregates(
let mut groups: HashMap<(String, DateTime<Utc>), Vec<f64>> = HashMap::new();
for (r, measured_at) in readings {
// 尝试从 values 中提取数值用于聚合
let hour_start = measured_at
.with_minute(0)
.and_then(|t| t.with_second(0))
@@ -247,22 +246,32 @@ async fn upsert_hourly_aggregates(
}
}
if groups.is_empty() {
return Ok(());
}
// 批量查出所有已存在的聚合记录(一次查询)
let existing_records = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.all(db)
.await?;
let existing_map: HashMap<(String, DateTime<Utc>), vital_signs_hourly::Model> = existing_records
.into_iter()
.map(|r| ((r.device_type.clone(), r.hour_start), r))
.collect();
let now = Utc::now();
let mut to_insert: Vec<vital_signs_hourly::ActiveModel> = Vec::new();
for ((device_type, hour_start), values) in groups {
let min_val = values.iter().cloned().reduce(f64::min);
let max_val = values.iter().cloned().reduce(f64::max);
let avg_val = values.iter().sum::<f64>() / values.len() as f64;
let sample_count = values.len() as i32;
// 尝试查找已存在的聚合记录
let existing = vital_signs_hourly::Entity::find()
.filter(vital_signs_hourly::Column::TenantId.eq(tenant_id))
.filter(vital_signs_hourly::Column::PatientId.eq(patient_id))
.filter(vital_signs_hourly::Column::DeviceType.eq(&device_type))
.filter(vital_signs_hourly::Column::HourStart.eq(hour_start))
.one(db)
.await?;
if let Some(rec) = existing {
if let Some(rec) = existing_map.get(&(device_type.clone(), hour_start)) {
// 合并:重新计算聚合
let total_count = rec.sample_count + sample_count;
let combined_avg = (rec.avg_val * rec.sample_count as f64 + avg_val * sample_count as f64)
@@ -270,16 +279,16 @@ async fn upsert_hourly_aggregates(
let combined_min = rec.min_val.map_or(min_val, |m| min_val.map_or(Some(m), |v| Some(m.min(v)))).or(min_val);
let combined_max = rec.max_val.map_or(max_val, |m| max_val.map_or(Some(m), |v| Some(m.max(v)))).or(max_val);
let mut active: vital_signs_hourly::ActiveModel = rec.into();
let mut active: vital_signs_hourly::ActiveModel = rec.clone().into();
active.min_val = Set(combined_min);
active.max_val = Set(combined_max);
active.avg_val = Set(combined_avg);
active.sample_count = Set(total_count);
active.updated_at = Set(Utc::now());
active.updated_at = Set(now);
active.version = Set(active.version.unwrap() + 1);
active.update(db).await?;
} else {
let model = vital_signs_hourly::ActiveModel {
to_insert.push(vital_signs_hourly::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
@@ -289,14 +298,21 @@ async fn upsert_hourly_aggregates(
max_val: Set(max_val),
avg_val: Set(avg_val),
sample_count: Set(sample_count),
created_at: Set(Utc::now()),
updated_at: Set(Utc::now()),
created_at: Set(now),
updated_at: Set(now),
version: Set(1),
};
model.insert(db).await?;
});
}
}
// 批量插入新增记录
if !to_insert.is_empty() {
vital_signs_hourly::Entity::insert_many(to_insert)
.exec(db)
.await
.map_err(|e| HealthError::DbError(e.to_string()))?;
}
Ok(())
}

View File

@@ -13,6 +13,7 @@ use erp_core::types::PaginatedResponse;
use crate::dto::follow_up_dto::*;
use crate::entity::{follow_up_record, follow_up_task, patient};
use std::collections::{HashMap, HashSet};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_follow_up_type;
use crate::state::HealthState;
@@ -51,8 +52,25 @@ pub async fn list_tasks(
.await?;
let total_pages = total.div_ceil(limit.max(1));
// 批量查询 patient_name
let patient_ids: HashSet<Uuid> = models.iter().map(|m| m.patient_id).collect();
let patient_names: HashMap<Uuid, String> = if !patient_ids.is_empty() {
patient::Entity::find()
.filter(patient::Column::Id.is_in(patient_ids))
.filter(patient::Column::TenantId.eq(tenant_id))
.all(&state.db)
.await?
.into_iter()
.map(|p| (p.id, p.name))
.collect()
} else {
HashMap::new()
};
let data = models.into_iter().map(|m| FollowUpTaskResp {
id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to,
patient_name: patient_names.get(&m.patient_id).cloned(),
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,
@@ -77,6 +95,7 @@ pub async fn get_task(
Ok(FollowUpTaskResp {
id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to,
patient_name: None,
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,
@@ -137,6 +156,7 @@ pub async fn create_task(
Ok(FollowUpTaskResp {
id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to,
patient_name: None,
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,
@@ -207,6 +227,7 @@ pub async fn update_task(
Ok(FollowUpTaskResp {
id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to,
patient_name: None,
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,

View File

@@ -428,42 +428,44 @@ pub async fn get_health_summary(
find_patient(&state.db, tenant_id, patient_id).await?;
use crate::entity::{vital_signs, lab_report, appointment, follow_up_task};
use sea_orm::QueryOrder;
// 最新体征
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?;
// 4 个查询并行执行
let (latest_vitals_res, latest_lab_res, upcoming_res, pending_follow_ups_res) = tokio::join!(
// 最新体征
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),
// 最新化验
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),
// 待处理预约数
appointment::Entity::find()
.filter(appointment::Column::TenantId.eq(tenant_id))
.filter(appointment::Column::PatientId.eq(patient_id))
.filter(appointment::Column::Status.eq("pending"))
.filter(appointment::Column::DeletedAt.is_null())
.count(&state.db),
// 待办随访数
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),
);
// 最新化验
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("pending"))
.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?;
let latest_vitals = latest_vitals_res?;
let latest_lab = latest_lab_res?;
let upcoming = upcoming_res?;
let pending_follow_ups = pending_follow_ups_res?;
Ok(serde_json::json!({
"patient_id": patient_id,

View File

@@ -96,32 +96,44 @@ pub async fn get_follow_up_statistics(
) -> AppResult<FollowUpStatisticsResp> {
let db = &state.db;
let total_tasks = follow_up_task::Entity::find()
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.count(db)
.await?;
// 单次 GROUP BY 查询替代 4 次独立 COUNT
let sql = r#"
SELECT COALESCE(status, '__total') AS status, COUNT(*) AS cnt
FROM follow_up_task
WHERE tenant_id = $1 AND deleted_at IS NULL
GROUP BY GROUPING SETS ((status), ())
"#;
let completed = follow_up_task::Entity::find()
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.filter(follow_up_task::Column::Status.eq("completed"))
.count(db)
.await?;
#[derive(Debug, sea_orm::FromQueryResult)]
struct StatusCount {
status: String,
cnt: i64,
}
let pending = follow_up_task::Entity::find()
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.filter(follow_up_task::Column::Status.eq("pending"))
.count(db)
.await?;
let rows: Vec<StatusCount> = sea_orm::FromQueryResult::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into()],
),
)
.all(db)
.await?;
let overdue = follow_up_task::Entity::find()
.filter(follow_up_task::Column::TenantId.eq(tenant_id))
.filter(follow_up_task::Column::DeletedAt.is_null())
.filter(follow_up_task::Column::Status.eq("overdue"))
.count(db)
.await?;
let mut total_tasks: i64 = 0;
let mut completed: i64 = 0;
let mut pending: i64 = 0;
let mut overdue: i64 = 0;
for row in &rows {
match row.status.as_str() {
"__total" => total_tasks = row.cnt,
"completed" => completed = row.cnt,
"pending" => pending = row.cnt,
"overdue" => overdue = row.cnt,
_ => {}
}
}
let completion_rate = if completed + pending + overdue > 0 {
(completed as f64 / (completed + pending + overdue) as f64) * 100.0
@@ -130,10 +142,10 @@ pub async fn get_follow_up_statistics(
};
Ok(FollowUpStatisticsResp {
total_tasks: total_tasks as i64,
completed: completed as i64,
pending: pending as i64,
overdue: overdue as i64,
total_tasks,
completed,
pending,
overdue,
completion_rate,
})
}
@@ -420,36 +432,36 @@ struct AvgFieldResult {
avg_val: Option<f64>,
}
macro_rules! avg_field_sql {
($field:literal) => {
concat!(
"SELECT AVG(", $field, ")::FLOAT8 AS avg_val FROM dialysis_record ",
"WHERE tenant_id = $1 AND deleted_at IS NULL AND ", $field, " IS NOT NULL ",
"AND created_at >= date_trunc('month', NOW())"
)
};
}
async fn compute_avg_field(
db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid,
field: &str,
) -> AppResult<Option<f64>> {
const ALLOWED_FIELDS: &[&str] = &[
"ultrafiltration_volume",
"dialysis_duration",
"uf_volume",
"uf_rate",
"blood_flow_rate",
"dialysate_flow_rate",
"pre_weight",
"post_weight",
"pre_bp_systolic",
"pre_bp_diastolic",
"post_bp_systolic",
"post_bp_diastolic",
];
if !ALLOWED_FIELDS.contains(&field) {
return Err(erp_core::error::AppError::Validation(format!(
"不允许的字段名: {field}"
)));
}
// field is whitelist-validated, safe to interpolate
let sql = format!(
"SELECT AVG({field})::FLOAT8 AS avg_val FROM dialysis_record \
WHERE tenant_id = $1 AND deleted_at IS NULL AND {field} IS NOT NULL \
AND created_at >= date_trunc('month', NOW())"
);
let sql = match field {
"ultrafiltration_volume" => avg_field_sql!("ultrafiltration_volume"),
"dialysis_duration" => avg_field_sql!("dialysis_duration"),
"uf_volume" => avg_field_sql!("uf_volume"),
"uf_rate" => avg_field_sql!("uf_rate"),
"blood_flow_rate" => avg_field_sql!("blood_flow_rate"),
"dialysate_flow_rate" => avg_field_sql!("dialysate_flow_rate"),
"pre_weight" => avg_field_sql!("pre_weight"),
"post_weight" => avg_field_sql!("post_weight"),
"pre_bp_systolic" => avg_field_sql!("pre_bp_systolic"),
"pre_bp_diastolic" => avg_field_sql!("pre_bp_diastolic"),
"post_bp_systolic" => avg_field_sql!("post_bp_systolic"),
"post_bp_diastolic" => avg_field_sql!("post_bp_diastolic"),
_ => return Err(erp_core::error::AppError::Validation(format!("不允许的字段名: {field}"))),
};
let result: Option<AvgFieldResult> = sea_orm::FromQueryResult::find_by_statement(
sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,