feat(health): 健康数据统计 — 透析/化验/预约/体征上报率
- 新增 6 个统计端点: dialysis, lab-reports, appointments, vital-signs-report-rate, health-data(综合) - 透析统计: 类型分布/并发症率/平均超滤/平均时长 - 化验统计: 类型分布/异常项计数/审核状态 - 预约统计: 状态/类型分布/取消率 - 体征上报率: 月度上报率 + 近 7 天趋势 - Web 统计面板增加健康数据中心区块
This commit is contained in:
@@ -148,6 +148,62 @@ export interface OverviewStatistics {
|
|||||||
points: PointsStatistics;
|
points: PointsStatistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Health Data Statistics Types ---
|
||||||
|
|
||||||
|
export interface NameValue {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialysisStatistics {
|
||||||
|
total_records: number;
|
||||||
|
this_month: number;
|
||||||
|
type_distribution: NameValue[];
|
||||||
|
complication_rate: number;
|
||||||
|
avg_ultrafiltration: number | null;
|
||||||
|
avg_duration: number | null;
|
||||||
|
pending_review: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LabReportStatistics {
|
||||||
|
total_reports: number;
|
||||||
|
this_month: number;
|
||||||
|
type_distribution: NameValue[];
|
||||||
|
abnormal_items: number;
|
||||||
|
pending_review: number;
|
||||||
|
reviewed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppointmentStatistics {
|
||||||
|
total_appointments: number;
|
||||||
|
this_month: number;
|
||||||
|
status_distribution: NameValue[];
|
||||||
|
type_distribution: NameValue[];
|
||||||
|
cancel_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyReportRate {
|
||||||
|
date: string;
|
||||||
|
reported: number;
|
||||||
|
total: number;
|
||||||
|
rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VitalSignsReportRate {
|
||||||
|
total_patients: number;
|
||||||
|
reported_patients: number;
|
||||||
|
report_rate: number;
|
||||||
|
total_records: number;
|
||||||
|
daily_trend: DailyReportRate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthDataStats {
|
||||||
|
dialysis: DialysisStatistics;
|
||||||
|
lab_reports: LabReportStatistics;
|
||||||
|
appointments: AppointmentStatistics;
|
||||||
|
vital_signs_report_rate: VitalSignsReportRate;
|
||||||
|
}
|
||||||
|
|
||||||
// --- API ---
|
// --- API ---
|
||||||
|
|
||||||
export const pointsApi = {
|
export const pointsApi = {
|
||||||
@@ -295,4 +351,12 @@ export const pointsApi = {
|
|||||||
}>('/health/admin/statistics/follow-ups');
|
}>('/health/admin/statistics/follow-ups');
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getHealthDataStats: async (): Promise<HealthDataStats> => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: HealthDataStats;
|
||||||
|
}>('/health/admin/statistics/health-data');
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Typography,
|
Typography,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Tag,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
type ConsultationStatistics,
|
type ConsultationStatistics,
|
||||||
type FollowUpStatistics,
|
type FollowUpStatistics,
|
||||||
type PointsStatistics,
|
type PointsStatistics,
|
||||||
|
type HealthDataStats,
|
||||||
} from '../../api/health/points';
|
} from '../../api/health/points';
|
||||||
|
|
||||||
const { Title: AntTitle, Text } = Typography;
|
const { Title: AntTitle, Text } = Typography;
|
||||||
@@ -85,21 +87,24 @@ export default function StatisticsDashboard() {
|
|||||||
const [consultationStats, setConsultationStats] = useState<ConsultationStatistics | null>(null);
|
const [consultationStats, setConsultationStats] = useState<ConsultationStatistics | null>(null);
|
||||||
const [followUpStats, setFollowUpStats] = useState<FollowUpStatistics | null>(null);
|
const [followUpStats, setFollowUpStats] = useState<FollowUpStatistics | null>(null);
|
||||||
const [pointsStats, setPointsStats] = useState<PointsStatistics | null>(null);
|
const [pointsStats, setPointsStats] = useState<PointsStatistics | null>(null);
|
||||||
|
const [healthDataStats, setHealthDataStats] = useState<HealthDataStats | null>(null);
|
||||||
|
|
||||||
const fetchAllStats = useCallback(async () => {
|
const fetchAllStats = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [patients, consultations, followUps, points] = await Promise.all([
|
const [patients, consultations, followUps, points, healthData] = await Promise.all([
|
||||||
pointsApi.getPatientStats(),
|
pointsApi.getPatientStats(),
|
||||||
pointsApi.getConsultationStats(),
|
pointsApi.getConsultationStats(),
|
||||||
pointsApi.getFollowUpStats(),
|
pointsApi.getFollowUpStats(),
|
||||||
pointsApi.getStatistics(),
|
pointsApi.getStatistics(),
|
||||||
|
pointsApi.getHealthDataStats(),
|
||||||
]);
|
]);
|
||||||
setPatientStats(patients);
|
setPatientStats(patients);
|
||||||
setConsultationStats(consultations);
|
setConsultationStats(consultations);
|
||||||
setFollowUpStats(followUps);
|
setFollowUpStats(followUps);
|
||||||
setPointsStats(points);
|
setPointsStats(points);
|
||||||
|
setHealthDataStats(healthData);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : '加载统计数据失败';
|
const message = err instanceof Error ? err.message : '加载统计数据失败';
|
||||||
setError(message);
|
setError(message);
|
||||||
@@ -336,6 +341,159 @@ export default function StatisticsDashboard() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Section 2.5: Health Data Statistics */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||||
|
<MedicineBoxOutlined style={{ marginRight: 8, color: '#2563eb' }} />
|
||||||
|
健康数据中心
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
style={{ borderRadius: 12 }}
|
||||||
|
>
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{/* 透析统计 */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card
|
||||||
|
type="inner"
|
||||||
|
title={<span style={{ fontSize: 14, fontWeight: 600 }}>透析记录</span>}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="总记录" value={healthDataStats?.dialysis.total_records ?? 0} valueStyle={{ fontSize: 20 }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="本月新增" value={healthDataStats?.dialysis.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="待审核" value={healthDataStats?.dialysis.pending_review ?? 0} valueStyle={{ fontSize: 20, color: '#d97706' }} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="并发症率" value={healthDataStats?.dialysis.complication_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 18 }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="平均超滤(ml)" value={healthDataStats?.dialysis.avg_ultrafiltration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="平均时长(分)" value={healthDataStats?.dialysis.avg_duration ?? 0} precision={0} valueStyle={{ fontSize: 18 }} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{(healthDataStats?.dialysis.type_distribution ?? []).length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
||||||
|
{healthDataStats!.dialysis.type_distribution.map((item) => (
|
||||||
|
<Tag key={item.name} color="blue" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 化验报告 */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card
|
||||||
|
type="inner"
|
||||||
|
title={<span style={{ fontSize: 14, fontWeight: 600 }}>化验报告</span>}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="总报告" value={healthDataStats?.lab_reports.total_reports ?? 0} valueStyle={{ fontSize: 20 }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="本月新增" value={healthDataStats?.lab_reports.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="异常项" value={healthDataStats?.lab_reports.abnormal_items ?? 0} valueStyle={{ fontSize: 20, color: '#dc2626' }} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={[12, 12]} style={{ marginTop: 12 }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic title="待审核" value={healthDataStats?.lab_reports.pending_review ?? 0} valueStyle={{ fontSize: 18, color: '#d97706' }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Statistic title="已审核" value={healthDataStats?.lab_reports.reviewed ?? 0} valueStyle={{ fontSize: 18, color: '#059669' }} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{(healthDataStats?.lab_reports.type_distribution ?? []).length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>类型分布: </Text>
|
||||||
|
{healthDataStats!.lab_reports.type_distribution.map((item) => (
|
||||||
|
<Tag key={item.name} color="green" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 预约统计 */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card
|
||||||
|
type="inner"
|
||||||
|
title={<span style={{ fontSize: 14, fontWeight: 600 }}>预约统计</span>}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="总预约" value={healthDataStats?.appointments.total_appointments ?? 0} valueStyle={{ fontSize: 20 }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="本月" value={healthDataStats?.appointments.this_month ?? 0} valueStyle={{ fontSize: 20, color: '#2563eb' }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="取消率" value={healthDataStats?.appointments.cancel_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#dc2626' }} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{(healthDataStats?.appointments.status_distribution ?? []).length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>状态: </Text>
|
||||||
|
{healthDataStats!.appointments.status_distribution.map((item) => (
|
||||||
|
<Tag key={item.name} color="purple" style={{ marginTop: 4 }}>{item.name}: {item.value}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 体征上报率 */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<Card
|
||||||
|
type="inner"
|
||||||
|
title={<span style={{ fontSize: 14, fontWeight: 600 }}>体征上报率</span>}
|
||||||
|
style={{ borderRadius: 8 }}
|
||||||
|
>
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="总患者" value={healthDataStats?.vital_signs_report_rate.total_patients ?? 0} valueStyle={{ fontSize: 20 }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="本月上报" value={healthDataStats?.vital_signs_report_rate.reported_patients ?? 0} valueStyle={{ fontSize: 20, color: '#059669' }} />
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Statistic title="上报率" value={healthDataStats?.vital_signs_report_rate.report_rate ?? 0} suffix="%" precision={1} valueStyle={{ fontSize: 20, color: '#7c3aed' }} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{(healthDataStats?.vital_signs_report_rate.daily_trend ?? []).length > 0 && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>近 7 天: </Text>
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||||
|
{healthDataStats!.vital_signs_report_rate.daily_trend.map((d) => (
|
||||||
|
<Tag key={d.date} color={d.rate >= 50 ? 'green' : d.rate >= 20 ? 'orange' : 'red'}>
|
||||||
|
{d.date.slice(5)} {d.reported}/{d.total}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Section 3: Quick Links */}
|
{/* Section 3: Quick Links */}
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 患者统计
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct PatientStatisticsResp {
|
pub struct PatientStatisticsResp {
|
||||||
pub total_patients: i64,
|
pub total_patients: i64,
|
||||||
@@ -33,3 +37,90 @@ pub struct DashboardStatsResp {
|
|||||||
pub consultations: ConsultationStatisticsResp,
|
pub consultations: ConsultationStatisticsResp,
|
||||||
pub follow_ups: FollowUpStatisticsResp,
|
pub follow_ups: FollowUpStatisticsResp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 健康数据统计
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct DialysisStatisticsResp {
|
||||||
|
pub total_records: i64,
|
||||||
|
pub this_month: i64,
|
||||||
|
/// 本月各透析类型分布
|
||||||
|
pub type_distribution: Vec<NameValue>,
|
||||||
|
/// 本月并发症发生率 (%)
|
||||||
|
pub complication_rate: f64,
|
||||||
|
/// 平均超滤量 (ml)
|
||||||
|
pub avg_ultrafiltration: Option<f64>,
|
||||||
|
/// 平均透析时长 (分钟)
|
||||||
|
pub avg_duration: Option<f64>,
|
||||||
|
/// 待审核数量
|
||||||
|
pub pending_review: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct LabReportStatisticsResp {
|
||||||
|
pub total_reports: i64,
|
||||||
|
pub this_month: i64,
|
||||||
|
/// 各报告类型分布
|
||||||
|
pub type_distribution: Vec<NameValue>,
|
||||||
|
/// 异常项数(items 中 is_abnormal=true 的总数)
|
||||||
|
pub abnormal_items: i64,
|
||||||
|
/// 待审核数量
|
||||||
|
pub pending_review: i64,
|
||||||
|
/// 已审核数量
|
||||||
|
pub reviewed: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct AppointmentStatisticsResp {
|
||||||
|
pub total_appointments: i64,
|
||||||
|
pub this_month: i64,
|
||||||
|
/// 各状态分布
|
||||||
|
pub status_distribution: Vec<NameValue>,
|
||||||
|
/// 各预约类型分布
|
||||||
|
pub type_distribution: Vec<NameValue>,
|
||||||
|
/// 取消率 (%)
|
||||||
|
pub cancel_rate: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct VitalSignsReportRateResp {
|
||||||
|
/// 总患者数
|
||||||
|
pub total_patients: i64,
|
||||||
|
/// 本月上报体征的患者数
|
||||||
|
pub reported_patients: i64,
|
||||||
|
/// 上报率 (%)
|
||||||
|
pub report_rate: f64,
|
||||||
|
/// 本月体征记录总数
|
||||||
|
pub total_records: i64,
|
||||||
|
/// 上报率趋势(最近 7 天)
|
||||||
|
pub daily_trend: Vec<DailyReportRate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct DailyReportRate {
|
||||||
|
pub date: String,
|
||||||
|
pub reported: i64,
|
||||||
|
pub total: i64,
|
||||||
|
pub rate: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 健康数据中心综合统计。
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct HealthDataStatsResp {
|
||||||
|
pub dialysis: DialysisStatisticsResp,
|
||||||
|
pub lab_reports: LabReportStatisticsResp,
|
||||||
|
pub appointments: AppointmentStatisticsResp,
|
||||||
|
pub vital_signs_report_rate: VitalSignsReportRateResp,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 通用结构
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct NameValue {
|
||||||
|
pub name: String,
|
||||||
|
pub value: i64,
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,3 +65,72 @@ where
|
|||||||
follow_ups,
|
follow_ups,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 健康数据统计
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn get_dialysis_stats<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<DialysisStatisticsResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
|
let result = stats_service::get_dialysis_statistics(&state, ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_lab_report_stats<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<LabReportStatisticsResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
|
let result = stats_service::get_lab_report_statistics(&state, ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_appointment_stats<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<AppointmentStatisticsResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
|
let result = stats_service::get_appointment_statistics(&state, ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_vital_signs_report_rate<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<VitalSignsReportRateResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
|
let result = stats_service::get_vital_signs_report_rate(&state, ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_health_data_stats<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<Json<ApiResponse<HealthDataStatsResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.patient.list")?;
|
||||||
|
let result = stats_service::get_health_data_stats(&state, ctx.tenant_id).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -482,6 +482,26 @@ impl HealthModule {
|
|||||||
"/health/admin/statistics/dashboard",
|
"/health/admin/statistics/dashboard",
|
||||||
axum::routing::get(stats_handler::get_dashboard_stats),
|
axum::routing::get(stats_handler::get_dashboard_stats),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/health/admin/statistics/dialysis",
|
||||||
|
axum::routing::get(stats_handler::get_dialysis_stats),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/admin/statistics/lab-reports",
|
||||||
|
axum::routing::get(stats_handler::get_lab_report_stats),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/admin/statistics/appointments",
|
||||||
|
axum::routing::get(stats_handler::get_appointment_stats),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/admin/statistics/vital-signs-report-rate",
|
||||||
|
axum::routing::get(stats_handler::get_vital_signs_report_rate),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/health/admin/statistics/health-data",
|
||||||
|
axum::routing::get(stats_handler::get_health_data_stats),
|
||||||
|
)
|
||||||
// 危急值阈值配置
|
// 危急值阈值配置
|
||||||
.route(
|
.route(
|
||||||
"/health/critical-value-thresholds",
|
"/health/critical-value-thresholds",
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult};
|
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, sea_query::Expr, FromQueryResult, FromQueryResult as _};
|
||||||
|
|
||||||
use erp_core::error::AppResult;
|
use erp_core::error::AppResult;
|
||||||
|
|
||||||
use crate::dto::stats_dto::*;
|
use crate::dto::stats_dto::*;
|
||||||
use crate::entity::{
|
use crate::entity::{
|
||||||
patient, consultation_session, follow_up_task,
|
patient, consultation_session, follow_up_task,
|
||||||
points_transaction,
|
points_transaction, dialysis_record, lab_report,
|
||||||
|
appointment, vital_signs,
|
||||||
};
|
};
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 基础运营统计
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub async fn get_patient_statistics(
|
pub async fn get_patient_statistics(
|
||||||
state: &HealthState,
|
state: &HealthState,
|
||||||
tenant_id: uuid::Uuid,
|
tenant_id: uuid::Uuid,
|
||||||
@@ -162,3 +167,425 @@ async fn compute_avg_response_time(
|
|||||||
|
|
||||||
Ok(result.and_then(|r| r.avg_minutes))
|
Ok(result.and_then(|r| r.avg_minutes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 健康数据统计
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn get_dialysis_statistics(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> AppResult<DialysisStatisticsResp> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
let total_records = dialysis_record::Entity::find()
|
||||||
|
.filter(dialysis_record::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dialysis_record::Column::DeletedAt.is_null())
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let this_month = dialysis_record::Entity::find()
|
||||||
|
.filter(dialysis_record::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dialysis_record::Column::DeletedAt.is_null())
|
||||||
|
.filter(Expr::col(dialysis_record::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let pending_review = dialysis_record::Entity::find()
|
||||||
|
.filter(dialysis_record::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dialysis_record::Column::DeletedAt.is_null())
|
||||||
|
.filter(dialysis_record::Column::Status.eq("draft"))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let type_distribution = count_by_field(
|
||||||
|
db, tenant_id,
|
||||||
|
"SELECT dialysis_type AS name, COUNT(*) AS value FROM dialysis_record \
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||||
|
AND created_at >= date_trunc('month', NOW()) \
|
||||||
|
GROUP BY dialysis_type ORDER BY value DESC",
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let complication_rate = compute_complication_rate(db, tenant_id).await?;
|
||||||
|
let avg_ultrafiltration = compute_avg_field(db, tenant_id, "ultrafiltration_volume").await?;
|
||||||
|
let avg_duration = compute_avg_field(db, tenant_id, "dialysis_duration").await?;
|
||||||
|
|
||||||
|
Ok(DialysisStatisticsResp {
|
||||||
|
total_records: total_records as i64,
|
||||||
|
this_month: this_month as i64,
|
||||||
|
type_distribution,
|
||||||
|
complication_rate,
|
||||||
|
avg_ultrafiltration,
|
||||||
|
avg_duration,
|
||||||
|
pending_review: pending_review as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_lab_report_statistics(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> AppResult<LabReportStatisticsResp> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
let total_reports = lab_report::Entity::find()
|
||||||
|
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(lab_report::Column::DeletedAt.is_null())
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let this_month = lab_report::Entity::find()
|
||||||
|
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(lab_report::Column::DeletedAt.is_null())
|
||||||
|
.filter(Expr::col(lab_report::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let pending_review = lab_report::Entity::find()
|
||||||
|
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(lab_report::Column::DeletedAt.is_null())
|
||||||
|
.filter(lab_report::Column::Status.eq("pending"))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let reviewed = lab_report::Entity::find()
|
||||||
|
.filter(lab_report::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(lab_report::Column::DeletedAt.is_null())
|
||||||
|
.filter(lab_report::Column::Status.eq("reviewed"))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let type_distribution = count_by_field(
|
||||||
|
db, tenant_id,
|
||||||
|
"SELECT report_type AS name, COUNT(*) AS value FROM lab_report \
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||||
|
AND created_at >= date_trunc('month', NOW()) \
|
||||||
|
GROUP BY report_type ORDER BY value DESC",
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let abnormal_items = count_abnormal_lab_items(db, tenant_id).await?;
|
||||||
|
|
||||||
|
Ok(LabReportStatisticsResp {
|
||||||
|
total_reports: total_reports as i64,
|
||||||
|
this_month: this_month as i64,
|
||||||
|
type_distribution,
|
||||||
|
abnormal_items,
|
||||||
|
pending_review: pending_review as i64,
|
||||||
|
reviewed: reviewed as i64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_appointment_statistics(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> AppResult<AppointmentStatisticsResp> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
let total_appointments = appointment::Entity::find()
|
||||||
|
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(appointment::Column::DeletedAt.is_null())
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let this_month = appointment::Entity::find()
|
||||||
|
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(appointment::Column::DeletedAt.is_null())
|
||||||
|
.filter(Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status_distribution = count_by_field(
|
||||||
|
db, tenant_id,
|
||||||
|
"SELECT status AS name, COUNT(*) AS value FROM appointment \
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||||
|
AND created_at >= date_trunc('month', NOW()) \
|
||||||
|
GROUP BY status ORDER BY value DESC",
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let type_distribution = count_by_field(
|
||||||
|
db, tenant_id,
|
||||||
|
"SELECT appointment_type AS name, COUNT(*) AS value FROM appointment \
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL \
|
||||||
|
AND created_at >= date_trunc('month', NOW()) \
|
||||||
|
GROUP BY appointment_type ORDER BY value DESC",
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
let cancelled = appointment::Entity::find()
|
||||||
|
.filter(appointment::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(appointment::Column::DeletedAt.is_null())
|
||||||
|
.filter(Expr::col(appointment::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
|
||||||
|
.filter(appointment::Column::Status.eq("cancelled"))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let cancel_rate = if this_month > 0 {
|
||||||
|
(cancelled as f64 / this_month as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AppointmentStatisticsResp {
|
||||||
|
total_appointments: total_appointments as i64,
|
||||||
|
this_month: this_month as i64,
|
||||||
|
status_distribution,
|
||||||
|
type_distribution,
|
||||||
|
cancel_rate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_vital_signs_report_rate(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> AppResult<VitalSignsReportRateResp> {
|
||||||
|
let db = &state.db;
|
||||||
|
|
||||||
|
let total_patients = patient::Entity::find()
|
||||||
|
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(patient::Column::DeletedAt.is_null())
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let total_records = vital_signs::Entity::find()
|
||||||
|
.filter(vital_signs::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||||
|
.filter(Expr::col(vital_signs::Column::CreatedAt).gte(Expr::cust("date_trunc('month', NOW())")))
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let reported_patients = count_distinct_patients_vital_signs(db, tenant_id).await?;
|
||||||
|
|
||||||
|
let report_rate = if total_patients > 0 {
|
||||||
|
(reported_patients as f64 / total_patients as f64) * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let daily_trend = compute_daily_report_rate(db, tenant_id).await?;
|
||||||
|
|
||||||
|
Ok(VitalSignsReportRateResp {
|
||||||
|
total_patients: total_patients as i64,
|
||||||
|
reported_patients: reported_patients as i64,
|
||||||
|
report_rate,
|
||||||
|
total_records: total_records as i64,
|
||||||
|
daily_trend,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_health_data_stats(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> AppResult<HealthDataStatsResp> {
|
||||||
|
let dialysis = get_dialysis_statistics(state, tenant_id).await?;
|
||||||
|
let lab_reports = get_lab_report_statistics(state, tenant_id).await?;
|
||||||
|
let appointments = get_appointment_statistics(state, tenant_id).await?;
|
||||||
|
let vital_signs_report_rate = get_vital_signs_report_rate(state, tenant_id).await?;
|
||||||
|
|
||||||
|
Ok(HealthDataStatsResp {
|
||||||
|
dialysis,
|
||||||
|
lab_reports,
|
||||||
|
appointments,
|
||||||
|
vital_signs_report_rate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 辅助查询
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
struct NameValueRow {
|
||||||
|
name: String,
|
||||||
|
value: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_by_field(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
sql: &str,
|
||||||
|
) -> AppResult<Vec<NameValue>> {
|
||||||
|
let rows: Vec<NameValueRow> = sea_orm::FromQueryResult::find_by_statement(
|
||||||
|
sea_orm::Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
[tenant_id.into()],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(|r| NameValue { name: r.name, value: r.value }).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
struct AvgFieldResult {
|
||||||
|
avg_val: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_avg_field(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
field: &str,
|
||||||
|
) -> AppResult<Option<f64>> {
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT AVG({field}) 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 result: Option<AvgFieldResult> = sea_orm::FromQueryResult::find_by_statement(
|
||||||
|
sea_orm::Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
[tenant_id.into()],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.and_then(|r| r.avg_val))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_complication_rate(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> AppResult<f64> {
|
||||||
|
let sql = r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE complication_notes IS NOT NULL AND complication_notes != '') AS with_comp,
|
||||||
|
COUNT(*) AS total
|
||||||
|
FROM dialysis_record
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||||
|
AND created_at >= date_trunc('month', NOW())
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
struct CompResult {
|
||||||
|
with_comp: i64,
|
||||||
|
total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Option<CompResult> = sea_orm::FromQueryResult::find_by_statement(
|
||||||
|
sea_orm::Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
[tenant_id.into()],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(match result {
|
||||||
|
Some(r) if r.total > 0 => (r.with_comp as f64 / r.total as f64) * 100.0,
|
||||||
|
_ => 0.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_abnormal_lab_items(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> AppResult<i64> {
|
||||||
|
let sql = r#"
|
||||||
|
SELECT COALESCE(SUM(jsonb_array_length(
|
||||||
|
COALESCE(
|
||||||
|
(SELECT jsonb_agg(elem) FROM jsonb_array_elements(items) elem WHERE elem->>'is_abnormal' = 'true'),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
)), 0) AS total
|
||||||
|
FROM lab_report
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL AND items IS NOT NULL
|
||||||
|
AND created_at >= date_trunc('month', NOW())
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
struct AbnormalCount {
|
||||||
|
total: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Option<AbnormalCount> = sea_orm::FromQueryResult::find_by_statement(
|
||||||
|
sea_orm::Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
[tenant_id.into()],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.and_then(|r| r.total).unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_distinct_patients_vital_signs(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> AppResult<u64> {
|
||||||
|
let sql = r#"
|
||||||
|
SELECT COUNT(DISTINCT patient_id) AS cnt
|
||||||
|
FROM vital_signs
|
||||||
|
WHERE tenant_id = $1 AND deleted_at IS NULL
|
||||||
|
AND created_at >= date_trunc('month', NOW())
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
struct DistinctCount {
|
||||||
|
cnt: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: Option<DistinctCount> = sea_orm::FromQueryResult::find_by_statement(
|
||||||
|
sea_orm::Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
[tenant_id.into()],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.one(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.map(|r| r.cnt as u64).unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_daily_report_rate(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: uuid::Uuid,
|
||||||
|
) -> AppResult<Vec<DailyReportRate>> {
|
||||||
|
let sql = r#"
|
||||||
|
SELECT d::date::text AS date,
|
||||||
|
COUNT(DISTINCT vs.patient_id) AS reported,
|
||||||
|
0 AS total
|
||||||
|
FROM generate_series(
|
||||||
|
CURRENT_DATE - INTERVAL '6 days',
|
||||||
|
CURRENT_DATE,
|
||||||
|
INTERVAL '1 day'
|
||||||
|
) d
|
||||||
|
LEFT JOIN vital_signs vs ON vs.record_date = d::date
|
||||||
|
AND vs.tenant_id = $1 AND vs.deleted_at IS NULL
|
||||||
|
GROUP BY d::date
|
||||||
|
ORDER BY d::date
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Debug, FromQueryResult)]
|
||||||
|
struct DailyRow {
|
||||||
|
date: String,
|
||||||
|
reported: i64,
|
||||||
|
total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<DailyRow> = 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 total_patients = patient::Entity::find()
|
||||||
|
.filter(patient::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(patient::Column::DeletedAt.is_null())
|
||||||
|
.count(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(|r| {
|
||||||
|
let total = total_patients as i64;
|
||||||
|
let rate = if total > 0 { (r.reported as f64 / total as f64) * 100.0 } else { 0.0 };
|
||||||
|
DailyReportRate { date: r.date, reported: r.reported, total, rate }
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user