Compare commits
11 Commits
59a22e762d
...
47df2e2aa6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47df2e2aa6 | ||
|
|
af44476c0f | ||
|
|
1c7184b6bc | ||
|
|
0929825ae7 | ||
|
|
0a387c189a | ||
|
|
04c5f3c0d5 | ||
|
|
f934ca0eaf | ||
|
|
c6856370c6 | ||
|
|
4a5dbaeaeb | ||
|
|
432f6e3554 | ||
|
|
c09f6ecdc8 |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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: '预约类型',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user