feat: deep statistical analytics — clinical, symptoms, incidence, env correlation, weekday

Adds a substantial layer of data-backed statistics (all grounded in verified,
clean source data — no fabricated metrics).

Backend (new routers/statistics.py, prefix /api/stats; +106 pytest still green):
- /inpatient-clinical: LOS dist + by-disease quartiles, cost dist + by-disease +
  cost-vs-LOS, outcome counts, admission-route counts, BMI-by-age, KPIs
  (5822 admissions, median LOS 4d, mean ¥6294, cure 99.1%, emergency 47%)
- /symptoms: 主诉 keyword frequencies (发热/咳嗽/肺炎…) + revisit ratio (36%)
- /incidence-rate: per-10k-population standardized rate by district (cases ÷ pop)
- /env-correlation: pollutant×cases Pearson + 7×7 pairwise matrix + PM2.5 scatter
- /temporal: weekday distribution (+ month/yoy returned but UI omits them — data
  is December-only, so seasonality/YoY would be misleading)

Frontend:
- NEW 住院临床分析 page (/analysis/clinical, nav 临床分析): 9 charts + KPI row —
  LOS histogram + box-by-disease, cost histogram + scatter + by-disease, outcome
  donut (severity-colored), admission-route donut, age-band BMI box
- DiseaseAnalysis: 主诉症状词频 horizontal bar + revisit ratio
- DistrictComparison: 标化发病率(每万人)with 病例数↔发病率 toggle (rate is
  epidemiologically correct; raw counts mislead by population)
- EnvironmentalHealth: pollutant-cases correlation bar + 7×7 correlation heatmap +
  PM2.5×cases scatter with least-squares regression line
- TrendAnalysis: 星期就诊分布 + honest "data is December-only" note
- statsApi client + types

Gates: tsc 0 · build ok · functional e2e 43/43 (incl 2 new clinical) · verified
live against real backend data via dev proxy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:42:52 +08:00
parent 33f0f497d3
commit 4df6c71628
19 changed files with 1963 additions and 10 deletions

View File

@@ -0,0 +1,104 @@
/**
* 住院临床分析页(/analysis/clinical验收测试。
* 与 user-flows.spec.ts 一致的鉴权策略addInitScript 注入 cbpoa_token
* 用 page.route 拦截 /api/**,对 inpatient-clinical 返回合法小样本,其余返回 {}。
*/
import { test, expect, Page } from '@playwright/test';
import { TESTIDS } from '../src/utils/testids';
const CLINICAL_FIXTURE = {
kpis: {
total_admissions: 5822,
median_los_days: 4,
mean_cost: 6294,
cure_rate: 0.991,
emergency_admit_ratio: 0.47,
},
los_histogram: [
{ bin_label: '1-2', count: 1200 },
{ bin_label: '3-4', count: 2100 },
{ bin_label: '5-7', count: 1500 },
],
los_by_disease: [
{ diagnosis: '肺炎', p25: 3, median: 5, p75: 7, n: 800 },
{ diagnosis: '支气管炎', p25: 2, median: 4, p75: 6, n: 600 },
],
cost_histogram: [
{ bin_label: '0-3k', count: 1800 },
{ bin_label: '3k-6k', count: 2200 },
],
cost_by_disease: [
{ diagnosis: '肺炎', mean_cost: 7200, n: 800 },
{ diagnosis: '支气管炎', mean_cost: 5100, n: 600 },
],
cost_vs_los: [
{ los: 3, cost: 5000 },
{ los: 5, cost: 7200 },
{ los: 7, cost: 9100 },
],
outcome_counts: [
{ outcome: '治愈', count: 3474 },
{ outcome: '好转', count: 2298 },
{ outcome: '其他', count: 35 },
{ outcome: '未愈', count: 12 },
{ outcome: '死亡', count: 3 },
],
admission_route_counts: [
{ route: '急诊', count: 2700 },
{ route: '门诊', count: 3122 },
],
bmi_by_age_band: [
{ age_band: '0-2', p25: 14, median: 16, p75: 18, n: 400 },
{ age_band: '3-6', p25: 15, median: 17, p75: 19, n: 500 },
],
};
async function seedAuthAndMockApi(page: Page) {
await page.addInitScript(() => {
localStorage.setItem('cbpoa_token', 'e2e-test-token');
});
await page.route('/api/**', (route) => {
const url = route.request().url();
if (url.includes('/stats/inpatient-clinical')) {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(CLINICAL_FIXTURE),
});
return;
}
// 其余接口返回空对象,本页不依赖。
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
}
test.describe('住院临床分析页', () => {
test.beforeEach(async ({ page }) => {
await seedAuthAndMockApi(page);
});
test('deep-link /analysis/clinical mounts page-clinical + clinical-kpis', async ({ page }) => {
await page.goto('/analysis/clinical');
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
});
test('no horizontal scroll at 375px', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/analysis/clinical');
await expect(page.locator(`[data-testid="${TESTIDS.pageClinical}"]`)).toBeVisible();
await expect(page.locator(`[data-testid="${TESTIDS.clinicalKpis}"]`)).toBeVisible();
const noHorizontalScroll = await page.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth
);
expect(noHorizontalScroll).toBe(true);
});
});