Files
CA/backend/routers/reports.py
Akiba So 8ddd8e87bb feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages

Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module

Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter

Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
2026-06-08 18:40:08 +08:00

442 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Router for CBPOA reports endpoints
Generates and manages risk assessment reports
"""
from fastapi import APIRouter, HTTPException, Query
from datetime import datetime, timedelta
from typing import List, Literal, Dict
import re
from config import DATA_DIR, REPORTS_DIR, RISK_HIGH
from models import (
ReportResponse,
ReportListResponse,
ReportMetadata,
ReportSummary,
ReportSection,
ReportRecommendation,
DiagnosisBreakdown,
)
from utils.date_helpers import get_latest_date, get_available_dates
from utils.geojson import parse_geojson_file
from data.case_loader import get_combined_data
router = APIRouter(prefix="/api/reports", tags=["reports"])
def calculate_report_summary(grids: List[dict], period_days: int, case_data=None) -> ReportSummary:
"""Calculate summary statistics for report"""
if not grids:
return ReportSummary(
total_cases=0,
avg_risk=0.0,
peak_risk_date="",
peak_risk_value=0.0,
high_risk_areas=0,
trend_direction="stable"
)
risk_values = [g["risk_value"] for g in grids]
avg_risk = sum(risk_values) / len(risk_values)
high_risk_count = sum(1 for v in risk_values if v >= RISK_HIGH)
peak_risk_value = max(risk_values)
peak_grid = next(g for g in grids if g["risk_value"] == peak_risk_value)
latest_date = get_latest_date()
peak_risk_date = latest_date
trend_direction = "stable"
if len(grids) > 0:
avg_3d = sum(g.get("risk_3d", g["risk_value"]) for g in grids) / len(grids)
if avg_risk > avg_3d * 1.05:
trend_direction = "worsening"
elif avg_risk < avg_3d * 0.95:
trend_direction = "improving"
if case_data is not None and len(case_data) > 0:
total_cases = len(case_data)
else:
total_cases = int(len(grids) * avg_risk * 0.1 * period_days)
return ReportSummary(
total_cases=total_cases,
avg_risk=round(avg_risk, 4),
peak_risk_date=peak_risk_date,
peak_risk_value=round(peak_risk_value, 4),
high_risk_areas=high_risk_count,
trend_direction=trend_direction
)
def compute_diagnosis_breakdown(case_data) -> list:
"""Compute diagnosis breakdown from case data. Returns list of dicts."""
if case_data is None or len(case_data) == 0:
return []
breakdown = case_data.groupby(['diagnosis', 'type']).size().unstack(fill_value=0)
if 'outpatient' not in breakdown.columns:
breakdown['outpatient'] = 0
if 'inpatient' not in breakdown.columns:
breakdown['inpatient'] = 0
breakdown['total'] = breakdown['outpatient'] + breakdown['inpatient']
return [
{"diagnosis": str(d), "outpatient": int(row['outpatient']),
"inpatient": int(row['inpatient']), "total": int(row['total'])}
for d, row in breakdown.sort_values('total', ascending=False).head(10).iterrows()
]
def generate_report_sections(summary: ReportSummary, grids: List[Dict], period_days: int) -> List[ReportSection]:
"""Generate report sections"""
sections = [
ReportSection(
title="执行摘要",
content=(
f"本期报告覆盖{period_days}天的监测数据。全市平均风险指数为{summary.avg_risk:.4f}"
f"共识别出{summary.high_risk_areas}个高风险区域。"
f"总体趋势{summary.trend_direction}"
f"峰值风险出现在{summary.peak_risk_date},风险值为{summary.peak_risk_value:.4f}"
),
charts=["overview_chart", "trend_line"]
),
ReportSection(
title="风险空间分布",
content=(
f"高风险区域主要集中在人口密集区域。"
f"平均风险值{summary.avg_risk:.4f},表明整体风险处于可控范围。"
f"建议加强对高风险网格的监测和干预措施。"
),
charts=["risk_map", "heatmap"]
),
ReportSection(
title="时间趋势分析",
content=(
f"过去{period_days}天内,风险水平呈现{summary.trend_direction}趋势。"
f"累计报告病例约{summary.total_cases}例。"
f"需要持续关注风险变化趋势,及时调整防控策略。"
),
charts=["time_series", "daily_comparison"]
),
ReportSection(
title="重点区域识别",
content=(
f"识别出{summary.high_risk_areas}个高风险网格,需要优先关注。"
f"建议对这些区域实施精准防控措施,加强监测频率。"
),
charts=["hotspot_map", "district_ranking"]
),
]
return sections
def generate_recommendations(summary: ReportSummary, grids: List[Dict]) -> List[ReportRecommendation]:
"""Generate report recommendations"""
recommendations = []
if summary.high_risk_areas > 0:
high_risk_grids = [g["grid_id"] for g in grids if g["risk_value"] >= RISK_HIGH][:5]
recommendations.append(
ReportRecommendation(
priority="high",
category="intervention",
title="加强高风险区域干预",
description=f"{summary.high_risk_areas}个高风险区域实施精准干预措施,包括增加监测频次、加强防控力度。",
target_areas=high_risk_grids
)
)
if summary.trend_direction == "worsening":
recommendations.append(
ReportRecommendation(
priority="high",
category="monitoring",
title="提升监测预警级别",
description="风险趋势恶化,建议提升监测预警级别,增加数据采集频率,密切跟踪风险变化。",
target_areas=[]
)
)
recommendations.append(
ReportRecommendation(
priority="medium",
category="prevention",
title="加强健康宣教",
description="在人口密集区域加强健康宣教,提高公众防护意识,减少暴露风险。",
target_areas=[]
)
)
recommendations.append(
ReportRecommendation(
priority="medium",
category="resource_allocation",
title="优化资源配置",
description="根据风险分布优化医疗资源配置,确保高风险区域有充足的医疗资源储备。",
target_areas=[]
)
)
if summary.avg_risk < 0.3:
recommendations.append(
ReportRecommendation(
priority="low",
category="monitoring",
title="维持常规监测",
description="当前风险水平较低,建议维持常规监测,保持防控力度不放松。",
target_areas=[]
)
)
return recommendations
def generate_report_id(report_type: str, date_str: str) -> str:
"""Generate unique report ID"""
return f"RPT-{report_type.upper()}-{date_str}"
@router.get("/list", response_model=ReportListResponse)
async def get_reports_list(
report_type: Literal["daily", "weekly", "monthly", "all"] = Query(
default="all",
description="Filter by report type"
),
limit: int = Query(default=20, ge=1, le=100, description="Maximum reports to return"),
):
"""
Get list of available reports
Args:
report_type: Filter by report type (daily, weekly, monthly, or all)
limit: Maximum number of reports to return (1-100)
Returns:
List of report metadata
"""
available_dates = get_available_dates(90)
reports = []
for date_str in available_dates[:limit]:
report_date = datetime.strptime(date_str, "%Y%m%d")
if report_type != "all":
if report_type == "daily":
pass
elif report_type == "weekly" and report_date.weekday() != 6:
continue
elif report_type == "monthly" and report_date.day != 1:
continue
reports.append(
ReportMetadata(
report_id=generate_report_id(report_type, date_str),
title=f"武汉市健康风险评估报告 ({date_str})",
type=report_type if report_type != "all" else "daily",
generated_at=datetime.now().isoformat(),
period_start=(report_date - timedelta(days=6)).strftime("%Y%m%d"),
period_end=date_str
)
)
return ReportListResponse(
reports=reports,
total=len(reports),
timestamp=datetime.now().isoformat()
)
@router.get("/{report_id}", response_model=ReportResponse)
async def get_report(report_id: str):
"""
Get full report by ID
Args:
report_id: Report identifier (e.g., RPT-DAILY-20240115)
Returns:
Full report with sections and recommendations
"""
match = re.search(r"RPT-\w+-([0-9]{8})", report_id)
if not match:
raise HTTPException(status_code=400, detail="Invalid report ID format")
date_str = match.group(1)
filepath = DATA_DIR / f"risk_{date_str}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date_str}")
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
report_date = datetime.strptime(date_str, "%Y%m%d")
report_type = "daily"
if report_date.weekday() == 6:
report_type = "weekly"
if report_date.day == 1:
report_type = "monthly"
period_days = 1 if report_type == "daily" else 7 if report_type == "weekly" else 30
# Load case data for the report's date range
case_data = None
try:
case_data = get_combined_data()
if case_data is not None and len(case_data) > 0:
report_start = datetime.strptime(date_str, "%Y%m%d") - timedelta(days=period_days - 1)
report_end = datetime.strptime(date_str, "%Y%m%d")
case_data = case_data[
(case_data['date'] >= report_start) &
(case_data['date'] <= report_end)
]
except Exception:
case_data = None
summary = calculate_report_summary(grids, period_days, case_data)
sections = generate_report_sections(summary, grids, period_days)
recommendations = generate_recommendations(summary, grids)
diagnosis_breakdown = compute_diagnosis_breakdown(case_data)
metadata = ReportMetadata(
report_id=report_id,
title=f"武汉市健康风险评估报告 ({date_str})",
type=report_type,
generated_at=datetime.now().isoformat(),
period_start=(report_date - timedelta(days=period_days-1)).strftime("%Y%m%d"),
period_end=date_str,
author="CBPOA System"
)
attachments = [
f"/reports/{date_str}/summary.pdf",
f"/reports/{date_str}/maps.zip",
f"/reports/{date_str}/data.csv"
]
return ReportResponse(
metadata=metadata,
summary=summary,
sections=sections,
recommendations=recommendations,
attachments=attachments,
timestamp=datetime.now().isoformat(),
diagnosis_breakdown=[DiagnosisBreakdown(**d) for d in diagnosis_breakdown],
)
@router.get("/generate/{report_type}", response_model=ReportResponse)
async def generate_new_report(
report_type: Literal["daily", "weekly", "monthly"],
date: str | None = Query(default=None, description="Date in YYYYMMDD format"),
):
"""
Generate a new report
Args:
report_type: Type of report to generate (daily, weekly, monthly)
date: Optional date in YYYYMMDD format. Defaults to latest.
Returns:
Newly generated report
"""
if date is None:
date = get_latest_date()
try:
report_date = datetime.strptime(date, "%Y%m%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYYMMDD.")
if report_type == "weekly" and report_date.weekday() != 6:
raise HTTPException(
status_code=400,
detail="Weekly reports can only be generated for Sundays (weekday 6)"
)
if report_type == "monthly" and report_date.day != 1:
raise HTTPException(
status_code=400,
detail="Monthly reports can only be generated for the 1st of the month"
)
filepath = DATA_DIR / f"risk_{date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
report_id = generate_report_id(report_type, date)
period_days = 1 if report_type == "daily" else 7 if report_type == "weekly" else 30
# Load case data for the report's date range
case_data = None
try:
case_data = get_combined_data()
if case_data is not None and len(case_data) > 0:
report_start = datetime.strptime(date, "%Y%m%d") - timedelta(days=period_days - 1)
report_end = datetime.strptime(date, "%Y%m%d")
case_data = case_data[
(case_data['date'] >= report_start) &
(case_data['date'] <= report_end)
]
except Exception:
case_data = None
summary = calculate_report_summary(grids, period_days, case_data)
sections = generate_report_sections(summary, grids, period_days)
recommendations = generate_recommendations(summary, grids)
diagnosis_breakdown = compute_diagnosis_breakdown(case_data)
metadata = ReportMetadata(
report_id=report_id,
title=f"武汉市健康风险评估报告 ({date})",
type=report_type,
generated_at=datetime.now().isoformat(),
period_start=(report_date - timedelta(days=period_days-1)).strftime("%Y%m%d"),
period_end=date,
author="CBPOA System"
)
attachments = [
f"/reports/{date}/summary.pdf",
f"/reports/{date}/maps.zip",
f"/reports/{date}/data.csv"
]
return ReportResponse(
metadata=metadata,
summary=summary,
sections=sections,
recommendations=recommendations,
attachments=attachments,
timestamp=datetime.now().isoformat(),
diagnosis_breakdown=[DiagnosisBreakdown(**d) for d in diagnosis_breakdown],
)
@router.get("/summary/latest", response_model=ReportSummary)
async def get_latest_summary():
"""
Get latest risk summary
Returns:
Current risk summary statistics
"""
latest_date = get_latest_date()
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail="No grid data found")
return calculate_report_summary(grids, 1)