371 lines
11 KiB
Python
371 lines
11 KiB
Python
|
|
"""
|
|||
|
|
医疗病例数据 API 路由
|
|||
|
|
|
|||
|
|
提供门诊和住院数据的统计、趋势、区域分布等接口
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from fastapi import APIRouter, HTTPException, Query
|
|||
|
|
from pydantic import BaseModel
|
|||
|
|
from typing import Optional
|
|||
|
|
from datetime import datetime, date
|
|||
|
|
import pandas as pd
|
|||
|
|
import re
|
|||
|
|
from pathlib import Path
|
|||
|
|
import json
|
|||
|
|
|
|||
|
|
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|||
|
|
|
|||
|
|
router = APIRouter(prefix="/api/cases", tags=["cases"])
|
|||
|
|
|
|||
|
|
# 数据缓存
|
|||
|
|
_cache = {
|
|||
|
|
"outpatient": None,
|
|||
|
|
"inpatient": None,
|
|||
|
|
"loaded_at": None,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 武汉市区映射
|
|||
|
|
WUHAN_DISTRICTS = {
|
|||
|
|
'江岸区': ['江岸'],
|
|||
|
|
'江汉区': ['江汉'],
|
|||
|
|
'武昌区': ['武昌'],
|
|||
|
|
'洪山区': ['洪山'],
|
|||
|
|
'汉阳区': ['汉阳'],
|
|||
|
|
'东西湖区': ['东西湖'],
|
|||
|
|
'黄陂区': ['黄陂'],
|
|||
|
|
'硚口区': ['硚口'],
|
|||
|
|
'江夏区': ['江夏'],
|
|||
|
|
'青山区': ['青山'],
|
|||
|
|
'新洲区': ['新洲'],
|
|||
|
|
'蔡甸区': ['蔡甸'],
|
|||
|
|
'东湖新技术开发区': ['东湖新技术开发区', '光谷'],
|
|||
|
|
'经开(汉南)区': ['经开', '汉南', '经济开发区'],
|
|||
|
|
'东湖生态旅游风景区': ['东湖生态旅游风景区']
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|||
|
|
DATA_DIR = PROJECT_ROOT / "Datas"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _extract_district(addr: str) -> str:
|
|||
|
|
"""从地址提取武汉市区名"""
|
|||
|
|
if pd.isna(addr):
|
|||
|
|
return '未知'
|
|||
|
|
addr = str(addr)
|
|||
|
|
for district, keywords in WUHAN_DISTRICTS.items():
|
|||
|
|
for kw in keywords:
|
|||
|
|
if kw in addr:
|
|||
|
|
return district
|
|||
|
|
return '其他'
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_data():
|
|||
|
|
"""加载并缓存数据"""
|
|||
|
|
if _cache["loaded_at"] is not None:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 加载门诊数据
|
|||
|
|
df_out = pd.read_excel(DATA_DIR / "view_门诊.xlsx")
|
|||
|
|
df_out['date'] = pd.to_datetime(df_out['门诊日期_re'])
|
|||
|
|
df_out['district'] = df_out['现住址区'].fillna('未知')
|
|||
|
|
_cache["outpatient"] = df_out
|
|||
|
|
|
|||
|
|
# 加载住院数据
|
|||
|
|
df_in = pd.read_excel(DATA_DIR / "view_住院.xlsx")
|
|||
|
|
df_in['date'] = pd.to_datetime(df_in['入院日期_re'])
|
|||
|
|
df_in['district'] = df_in['现住址_脱敏'].apply(_extract_district)
|
|||
|
|
_cache["inpatient"] = df_in
|
|||
|
|
|
|||
|
|
_cache["loaded_at"] = datetime.now()
|
|||
|
|
except Exception as e:
|
|||
|
|
raise RuntimeError(f"数据加载失败:{str(e)}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_combined_data():
|
|||
|
|
"""获取合并的病例数据"""
|
|||
|
|
_load_data()
|
|||
|
|
|
|||
|
|
df_out = _cache["outpatient"][['date', 'district', '初诊', '主诉']].copy()
|
|||
|
|
df_out['type'] = 'outpatient'
|
|||
|
|
df_out['diagnosis'] = df_out['初诊']
|
|||
|
|
|
|||
|
|
df_in = _cache["inpatient"][['date', 'district', '诊断名称']].copy()
|
|||
|
|
df_in['type'] = 'inpatient'
|
|||
|
|
df_in['diagnosis'] = df_in['诊断名称']
|
|||
|
|
df_in['主诉'] = None
|
|||
|
|
|
|||
|
|
return pd.concat([df_out, df_in], ignore_index=True)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============== Response Models ==============
|
|||
|
|
|
|||
|
|
class StatsResponse(BaseModel):
|
|||
|
|
"""统计数据响应"""
|
|||
|
|
total_outpatient: int
|
|||
|
|
total_inpatient: int
|
|||
|
|
date_range: dict
|
|||
|
|
top_districts: list
|
|||
|
|
top_diagnoses: list
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TrendPoint(BaseModel):
|
|||
|
|
"""趋势数据点"""
|
|||
|
|
date: str
|
|||
|
|
outpatient: int
|
|||
|
|
inpatient: int
|
|||
|
|
total: int
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TrendResponse(BaseModel):
|
|||
|
|
"""趋势数据响应"""
|
|||
|
|
trend: list[TrendPoint]
|
|||
|
|
summary: dict
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DistrictData(BaseModel):
|
|||
|
|
"""区域数据"""
|
|||
|
|
district: str
|
|||
|
|
outpatient: int
|
|||
|
|
inpatient: int
|
|||
|
|
total: int
|
|||
|
|
outpatient_ratio: float
|
|||
|
|
inpatient_ratio: float
|
|||
|
|
|
|||
|
|
|
|||
|
|
class DistrictsResponse(BaseModel):
|
|||
|
|
"""区域分布响应"""
|
|||
|
|
districts: list[DistrictData]
|
|||
|
|
total: int
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RealtimeData(BaseModel):
|
|||
|
|
"""实时数据"""
|
|||
|
|
today_outpatient: int
|
|||
|
|
today_inpatient: int
|
|||
|
|
today_total: int
|
|||
|
|
last_7d_avg: int
|
|||
|
|
change_ratio: float
|
|||
|
|
status: str
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============== API Endpoints ==============
|
|||
|
|
|
|||
|
|
@router.get("/stats", response_model=StatsResponse, summary="获取病例统计数据")
|
|||
|
|
async def get_cases_stats():
|
|||
|
|
"""
|
|||
|
|
获取病例总体统计信息
|
|||
|
|
|
|||
|
|
- 总门诊量、总住院量
|
|||
|
|
- 数据日期范围
|
|||
|
|
- 就诊量前 10 的区域
|
|||
|
|
- 最常见诊断前 10
|
|||
|
|
"""
|
|||
|
|
_load_data()
|
|||
|
|
|
|||
|
|
df_out = _cache["outpatient"]
|
|||
|
|
df_in = _cache["inpatient"]
|
|||
|
|
|
|||
|
|
# 计算统计
|
|||
|
|
total_outpatient = len(df_out)
|
|||
|
|
total_inpatient = len(df_in)
|
|||
|
|
|
|||
|
|
# 日期范围
|
|||
|
|
min_date = min(df_out['date'].min(), df_in['date'].min())
|
|||
|
|
max_date = max(df_out['date'].max(), df_in['date'].max())
|
|||
|
|
|
|||
|
|
# 区域统计
|
|||
|
|
out_districts = df_out[df_out['district'] != '未知']['district'].value_counts().head(10)
|
|||
|
|
in_districts = df_in[df_in['district'] != '其他']['district'].value_counts().head(10)
|
|||
|
|
|
|||
|
|
combined_districts = pd.concat([out_districts, in_districts]).groupby(level=0).sum().nlargest(10)
|
|||
|
|
top_districts = [{"district": d, "count": int(c)} for d, c in combined_districts.items()]
|
|||
|
|
|
|||
|
|
# 诊断统计
|
|||
|
|
out_diagnoses = df_out['初诊'].value_counts().head(10)
|
|||
|
|
in_diagnoses = df_in['诊断名称'].value_counts().head(10)
|
|||
|
|
|
|||
|
|
top_diagnoses = [
|
|||
|
|
{"diagnosis": str(d), "outpatient": int(out_diagnoses.get(d, 0)), "inpatient": int(in_diagnoses.get(d, 0))}
|
|||
|
|
for d in set(list(out_diagnoses.index[:5]) + list(in_diagnoses.index[:5]))
|
|||
|
|
][:10]
|
|||
|
|
|
|||
|
|
return StatsResponse(
|
|||
|
|
total_outpatient=total_outpatient,
|
|||
|
|
total_inpatient=total_inpatient,
|
|||
|
|
date_range={
|
|||
|
|
"start": min_date.strftime("%Y-%m-%d"),
|
|||
|
|
"end": max_date.strftime("%Y-%m-%d")
|
|||
|
|
},
|
|||
|
|
top_districts=top_districts,
|
|||
|
|
top_diagnoses=top_diagnoses
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/trend", response_model=TrendResponse, summary="获取病例趋势数据")
|
|||
|
|
async def get_cases_trend(
|
|||
|
|
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"),
|
|||
|
|
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)"),
|
|||
|
|
group_by: str = Query("day", description="分组粒度:day, week, month"),
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
获取病例时间趋势数据
|
|||
|
|
|
|||
|
|
- 支持按日、周、月分组
|
|||
|
|
- 可指定日期范围
|
|||
|
|
- 返回门诊、住院、总计趋势
|
|||
|
|
"""
|
|||
|
|
if start_date and not DATE_PATTERN.match(start_date):
|
|||
|
|
raise HTTPException(status_code=400, detail="Invalid start_date format. Use YYYY-MM-DD")
|
|||
|
|
if end_date and not DATE_PATTERN.match(end_date):
|
|||
|
|
raise HTTPException(status_code=400, detail="Invalid end_date format. Use YYYY-MM-DD")
|
|||
|
|
|
|||
|
|
df = _get_combined_data()
|
|||
|
|
|
|||
|
|
# 日期过滤
|
|||
|
|
if start_date:
|
|||
|
|
df = df[df['date'] >= pd.to_datetime(start_date)]
|
|||
|
|
if end_date:
|
|||
|
|
df = df[df['date'] <= pd.to_datetime(end_date)]
|
|||
|
|
|
|||
|
|
# 分组
|
|||
|
|
if group_by == "week":
|
|||
|
|
df['period'] = df['date'].dt.to_period('W').dt.start_time
|
|||
|
|
elif group_by == "month":
|
|||
|
|
df['period'] = df['date'].dt.to_period('M').dt.start_time
|
|||
|
|
else:
|
|||
|
|
df['period'] = df['date'].dt.date
|
|||
|
|
|
|||
|
|
# 聚合
|
|||
|
|
out_trend = df[df['type'] == 'outpatient'].groupby('period').size()
|
|||
|
|
in_trend = df[df['type'] == 'inpatient'].groupby('period').size()
|
|||
|
|
|
|||
|
|
periods = sorted(set(out_trend.index.tolist() + in_trend.index.tolist()))
|
|||
|
|
|
|||
|
|
trend = []
|
|||
|
|
total_out = total_in = 0
|
|||
|
|
for p in periods:
|
|||
|
|
out_count = int(out_trend.get(p, 0))
|
|||
|
|
in_count = int(in_trend.get(p, 0))
|
|||
|
|
total_out += out_count
|
|||
|
|
total_in += in_count
|
|||
|
|
trend.append(TrendPoint(
|
|||
|
|
date=pd.Timestamp(p).strftime("%Y-%m-%d"),
|
|||
|
|
outpatient=out_count,
|
|||
|
|
inpatient=in_count,
|
|||
|
|
total=out_count + in_count
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
return TrendResponse(
|
|||
|
|
trend=trend,
|
|||
|
|
summary={
|
|||
|
|
"total_outpatient": total_out,
|
|||
|
|
"total_inpatient": total_in,
|
|||
|
|
"period_count": len(periods),
|
|||
|
|
"avg_daily_outpatient": round(total_out / max(len(periods), 1), 2),
|
|||
|
|
"avg_daily_inpatient": round(total_in / max(len(periods), 1), 2),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/districts", response_model=DistrictsResponse, summary="获取区域分布数据")
|
|||
|
|
async def get_cases_districts(
|
|||
|
|
case_type: Optional[str] = Query(None, description="病例类型:outpatient, inpatient, all"),
|
|||
|
|
min_count: int = Query(10, description="最小病例数过滤"),
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
获取病例区域分布数据
|
|||
|
|
|
|||
|
|
- 支持按病例类型筛选
|
|||
|
|
- 可设置最小病例数过滤
|
|||
|
|
- 返回各区门诊、住院量及占比
|
|||
|
|
"""
|
|||
|
|
df = _get_combined_data()
|
|||
|
|
|
|||
|
|
# 类型过滤
|
|||
|
|
if case_type == "outpatient":
|
|||
|
|
df = df[df['type'] == 'outpatient']
|
|||
|
|
elif case_type == "inpatient":
|
|||
|
|
df = df[df['type'] == 'inpatient']
|
|||
|
|
|
|||
|
|
# 过滤未知区域
|
|||
|
|
df = df[(df['district'] != '未知') & (df['district'] != '其他')]
|
|||
|
|
|
|||
|
|
# 聚合
|
|||
|
|
district_stats = df.groupby(['district', 'type']).size().unstack(fill_value=0)
|
|||
|
|
|
|||
|
|
if 'outpatient' not in district_stats.columns:
|
|||
|
|
district_stats['outpatient'] = 0
|
|||
|
|
if 'inpatient' not in district_stats.columns:
|
|||
|
|
district_stats['inpatient'] = 0
|
|||
|
|
|
|||
|
|
district_stats['total'] = district_stats['outpatient'] + district_stats['inpatient']
|
|||
|
|
|
|||
|
|
# 过滤
|
|||
|
|
district_stats = district_stats[district_stats['total'] >= min_count]
|
|||
|
|
district_stats = district_stats.sort_values('total', ascending=False)
|
|||
|
|
|
|||
|
|
total = int(district_stats['total'].sum())
|
|||
|
|
|
|||
|
|
districts = []
|
|||
|
|
for district, row in district_stats.iterrows():
|
|||
|
|
districts.append(DistrictData(
|
|||
|
|
district=district,
|
|||
|
|
outpatient=int(row['outpatient']),
|
|||
|
|
inpatient=int(row['inpatient']),
|
|||
|
|
total=int(row['total']),
|
|||
|
|
outpatient_ratio=round(row['outpatient'] / row['total'] * 100, 2) if row['total'] > 0 else 0,
|
|||
|
|
inpatient_ratio=round(row['inpatient'] / row['total'] * 100, 2) if row['total'] > 0 else 0
|
|||
|
|
))
|
|||
|
|
|
|||
|
|
return DistrictsResponse(districts=districts, total=total)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/realtime", response_model=RealtimeData, summary="获取实时数据")
|
|||
|
|
async def get_cases_realtime():
|
|||
|
|
"""
|
|||
|
|
获取实时病例数据
|
|||
|
|
|
|||
|
|
- 今日就诊量
|
|||
|
|
- 近 7 日平均值
|
|||
|
|
- 变化率
|
|||
|
|
- 状态评估 (正常/偏高/偏低)
|
|||
|
|
"""
|
|||
|
|
df = _get_combined_data()
|
|||
|
|
|
|||
|
|
today = pd.Timestamp.today().normalize()
|
|||
|
|
last_7d = today - pd.Timedelta(days=7)
|
|||
|
|
|
|||
|
|
# 今日数据
|
|||
|
|
today_data = df[df['date'] >= today]
|
|||
|
|
today_total = len(today_data)
|
|||
|
|
today_out = len(today_data[today_data['type'] == 'outpatient'])
|
|||
|
|
today_in = len(today_data[today_data['type'] == 'inpatient'])
|
|||
|
|
|
|||
|
|
# 近 7 日平均
|
|||
|
|
last_7d_data = df[(df['date'] >= last_7d) & (df['date'] < today)]
|
|||
|
|
last_7d_avg = round(len(last_7d_data) / 7, 2) if len(last_7d_data) > 0 else 0
|
|||
|
|
|
|||
|
|
# 变化率
|
|||
|
|
if last_7d_avg > 0:
|
|||
|
|
change_ratio = round((today_total - last_7d_avg) / last_7d_avg * 100, 2)
|
|||
|
|
else:
|
|||
|
|
change_ratio = 0.0
|
|||
|
|
|
|||
|
|
# 状态评估
|
|||
|
|
if change_ratio > 20:
|
|||
|
|
status = "偏高"
|
|||
|
|
elif change_ratio < -20:
|
|||
|
|
status = "偏低"
|
|||
|
|
else:
|
|||
|
|
status = "正常"
|
|||
|
|
|
|||
|
|
return RealtimeData(
|
|||
|
|
today_outpatient=today_out,
|
|||
|
|
today_inpatient=today_in,
|
|||
|
|
today_total=today_total,
|
|||
|
|
last_7d_avg=last_7d_avg,
|
|||
|
|
change_ratio=change_ratio,
|
|||
|
|
status=status
|
|||
|
|
)
|