Files
hms/docs/superpowers/plans/2026-05-04-iot-fhir-v1-plan2-fhir-api.md
iven fa0a788cf9 docs(plan): IoT + FHIR V1 Plan 2 — FHIR API 层实施计划
4 Chunk 9 Task:FHIR 基础类型 + CapabilityStatement +
Patient/Observation 转换 + 6 资源端点 + $everything 操作。
分步 TDD 流程,每步有具体代码和验证命令。
2026-05-04 01:27:18 +08:00

44 KiB
Raw Permalink Blame History

IoT + FHIR V1 Plan 2: FHIR API 层

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在 erp-health 模块内新增 fhir 子模块,实现 FHIR R4 只读 API将 HMS 实体转换为标准 FHIR 资源Patient/Observation/Device/DiagnosticReport/Encounter/Practitioner/Appointment/Task支持搜索参数和 $everything 操作。

Architecture: FHIR 路由使用独立的 /fhir/R4/ 前缀,初期复用现有 JWT 认证中间件Plan 3 替换为 OAuth2。HMS→FHIR 转换层作为纯函数模块,每个资源类型一个转换函数。搜索参数通过 SeaORM 查询构建器实现。

Tech Stack: Rust / Axum / SeaORM / serde_json / FHIR R4

Spec: docs/superpowers/specs/2026-05-04-iot-fhir-platform-ecosystem-design.md §5

Depends on: Plan 1vital_signs_daily 表,用于长期趋势查询)


Chunk 1: FHIR 模块结构 + 基础类型

Task 1: FHIR 基础类型定义

Files:

  • Create: crates/erp-health/src/fhir/mod.rs

  • Create: crates/erp-health/src/fhir/types.rs

  • Step 1: 创建 fhir 模块目录

mkdir -p crates/erp-health/src/fhir
  • Step 2: 创建 FHIR 基础类型
// crates/erp-health/src/fhir/types.rs
use serde::Serialize;

/// FHIR 资源通用包装
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum FhirResource {
    Patient(Box<PatientResource>),
    Observation(Box<ObservationResource>),
    Device(Box<DeviceResource>),
    DiagnosticReport(Box<DiagnosticReportResource>),
    Encounter(Box<EncounterResource>),
    Practitioner(Box<PractitionerResource>),
    Appointment(Box<AppointmentResource>),
    Task(Box<TaskResource>),
    CapabilityStatement(Box<CapabilityStatementResource>),
    Bundle(Box<BundleResource>),
    OperationOutcome(Box<OperationOutcomeResource>),
}

/// FHIR Bundle搜索结果集
#[derive(Debug, Serialize)]
pub struct BundleResource {
    pub resource_type: String,
    #[serde(rename = "type")]
    pub bundle_type: String,
    pub total: Option<i64>,
    pub entry: Vec<BundleEntry>,
}

#[derive(Debug, Serialize)]
pub struct BundleEntry {
    pub resource: serde_json::Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub full_url: Option<String>,
}

/// FHIR 搜索参数
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
pub struct FhirSearchParams {
    #[serde(rename = "_id")]
    pub id: Option<String>,
    #[serde(rename = "_count")]
    pub count: Option<u64>,
    #[serde(rename = "_offset")]
    pub offset: Option<u64>,
    pub patient: Option<String>,
    pub category: Option<String>,
    pub code: Option<String>,
    pub date: Option<String>,
    pub name: Option<String>,
    pub identifier: Option<String>,
    pub status: Option<String>,
}

/// HMS device_type → FHIR Observation LOINC 映射
pub fn device_type_to_loinc(device_type: &str) -> Option<(&'static str, &'static str)> {
    match device_type {
        "heart_rate" => Some(("8867-4", "Heart rate")),
        "blood_oxygen" => Some(("2708-6", "Oxygen saturation in Arterial blood")),
        "blood_pressure" => Some(("85354-9", "Blood pressure panel")),
        "blood_glucose" => Some(("2339-0", "Glucose in Blood")),
        "temperature" => Some(("8310-5", "Body temperature")),
        "steps" => Some(("55423-8", "Number of steps in 24 hours")),
        "sleep" => Some(("93832-4", "Sleep duration")),
        "stress" => Some(("80319-1", "Stress level")), // 非标准 LOINC
        _ => None,
    }
}

/// HMS device_type → FHIR Observation category
pub fn device_type_to_category(device_type: &str) -> &'static str {
    match device_type {
        "heart_rate" | "blood_oxygen" | "blood_pressure" | "temperature" => "vital-signs",
        "steps" | "sleep" | "stress" => "activity",
        "blood_glucose" => "laboratory",
        _ => "survey",
    }
}

/// HMS device_type unit → FHIR UCUM unit
pub fn device_type_to_unit(device_type: &str) -> (&'static str, &'static str) {
    match device_type {
        "heart_rate" => ("beats/minute", "/min"),
        "blood_oxygen" => ("%", "%"),
        "blood_pressure" => ("mmHg", "mm[Hg]"),
        "blood_glucose" => ("mg/dL", "mg/dL"),
        "temperature" => ("°C", "Cel"),
        "steps" => ("steps", "{steps}"),
        "sleep" => ("hours", "h"),
        "stress" => ("score", "{score}"),
        _ => ("unknown", "unknown"),
    }
}

/// OperationOutcome错误响应
#[derive(Debug, Serialize)]
pub struct OperationOutcomeResource {
    pub resource_type: String,
    pub issue: Vec<OperationOutcomeIssue>,
}

#[derive(Debug, Serialize)]
pub struct OperationOutcomeIssue {
    pub severity: String,
    pub code: String,
    pub diagnostics: Option<String>,
}

/// CapabilityStatement 占位 — 后续 Task 完善
#[derive(Debug, Serialize)]
pub struct CapabilityStatementResource {
    pub resource_type: String,
    pub status: String,
    pub date: String,
    pub kind: String,
    pub fhir_version: String,
    pub format: Vec<String>,
    pub rest: Vec<serde_json::Value>,
}

// ===== 各资源的占位 struct后续 Task 逐一实现 =====

#[derive(Debug, Serialize)]
pub struct PatientResource {
    pub resource_type: String,
    pub id: String,
    pub name: Vec<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub gender: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub birth_date: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub identifier: Option<Vec<serde_json::Value>>,
    pub meta: serde_json::Value,
}

#[derive(Debug, Serialize)]
pub struct ObservationResource {
    pub resource_type: String,
    pub id: String,
    pub status: String,
    pub category: Vec<serde_json::Value>,
    pub code: serde_json::Value,
    pub subject: serde_json::Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub device: Option<serde_json::Value>,
    #[serde(rename = "effectiveDateTime")]
    pub effective_date_time: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub value_quantity: Option<serde_json::Value>,
    pub meta: serde_json::Value,
}

#[derive(Debug, Serialize)]
pub struct DeviceResource {
    pub resource_type: String,
    pub id: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub identifier: Option<Vec<serde_json::Value>>,
    #[serde(rename = "displayName")]
    pub display_name: Option<String>,
    pub status: String,
    pub meta: serde_json::Value,
}

#[derive(Debug, Serialize)]
pub struct DiagnosticReportResource {
    pub resource_type: String,
    pub id: String,
    pub status: String,
    pub code: serde_json::Value,
    pub subject: serde_json::Value,
    #[serde(rename = "effectiveDateTime")]
    pub effective_date_time: Option<String>,
    pub meta: serde_json::Value,
}

#[derive(Debug, Serialize)]
pub struct EncounterResource {
    pub resource_type: String,
    pub id: String,
    pub status: String,
    pub class: serde_json::Value,
    pub subject: serde_json::Value,
    pub meta: serde_json::Value,
}

#[derive(Debug, Serialize)]
pub struct PractitionerResource {
    pub resource_type: String,
    pub id: String,
    pub name: Vec<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub qualification: Option<Vec<serde_json::Value>>,
    pub meta: serde_json::Value,
}

#[derive(Debug, Serialize)]
pub struct AppointmentResource {
    pub resource_type: String,
    pub id: String,
    pub status: String,
    pub participant: Vec<serde_json::Value>,
    #[serde(rename = "start")]
    pub start: Option<String>,
    #[serde(rename = "end")]
    pub end: Option<String>,
    pub meta: serde_json::Value,
}

#[derive(Debug, Serialize)]
pub struct TaskResource {
    pub resource_type: String,
    pub id: String,
    pub status: String,
    pub intent: String,
    pub focus: serde_json::Value,
    #[serde(rename = "for")]
    pub for_entity: serde_json::Value,
    pub meta: serde_json::Value,
}
  • Step 3: 创建 fhir/mod.rs
// crates/erp-health/src/fhir/mod.rs
pub mod types;
pub mod converter;
pub mod handler;

pub use types::*;
  • Step 4: 在 erp-health lib.rs 或 mod.rs 注册 fhir 模块

crates/erp-health/src/lib.rs(或主 mod.rs中添加

pub mod fhir;
  • Step 5: 创建占位文件以通过编译
touch crates/erp-health/src/fhir/converter.rs
touch crates/erp-health/src/fhir/handler.rs

converter.rshandler.rs 中各放一行注释占位:

// converter.rs — HMS → FHIR 转换函数
// handler.rs — FHIR R4 Axum handlers
  • Step 6: 验证编译

Run: cargo check -p erp-health Expected: 编译通过

  • Step 7: Commit
git add crates/erp-health/src/fhir/
git add crates/erp-health/src/lib.rs
git commit -m "feat(health): FHIR 基础类型 — 资源 struct + LOINC 映射 + Bundle + 搜索参数"

Task 2: CapabilityStatement endpoint

Files:

  • Modify: crates/erp-health/src/fhir/handler.rs

  • Step 1: 实现 CapabilityStatement handler

// crates/erp-health/src/fhir/handler.rs
use axum::extract::State;
use axum::response::IntoResponse;
use erp_core::types::TenantContext;
use axum::Extension;

use super::types::*;

type HealthState = crate::state::HealthState;

pub async fn capability_statement<S>(
) -> Result<impl IntoResponse, erp_core::error::AppError>
where
    crate::state::HealthState: axum::extract::FromRef<S>,
    S: Clone + Send + Sync + 'static,
{
    let stmt = CapabilityStatementResource {
        resource_type: "CapabilityStatement".into(),
        status: "active".into(),
        date: chrono::Utc::now().format("%Y-%m-%d").to_string(),
        kind: "instance".into(),
        fhir_version: "4.0.1".into(),
        format: vec!["application/fhir+json".into()],
        rest: vec![serde_json::json!({
            "mode": "server",
            "resource": [
                { "type": "Patient", "interaction": [{"code": "read"}, {"code": "search-type"}] },
                { "type": "Observation", "interaction": [{"code": "read"}, {"code": "search-type"}] },
                { "type": "Device", "interaction": [{"code": "read"}, {"code": "search-type"}] },
                { "type": "DiagnosticReport", "interaction": [{"code": "read"}, {"code": "search-type"}] },
                { "type": "Encounter", "interaction": [{"code": "read"}, {"code": "search-type"}] },
                { "type": "Practitioner", "interaction": [{"code": "read"}, {"code": "search-type"}] },
                { "type": "Appointment", "interaction": [{"code": "read"}, {"code": "search-type"}] },
                { "type": "Task", "interaction": [{"code": "read"}, {"code": "search-type"}] },
            ],
            "operation": [
                { "name": "everything", "definition": "/fhir/R4/Patient/{id}/$everything" },
                { "name": "lastn", "definition": "/fhir/R4/Observation/$lastn" },
            ]
        })],
    };

    Ok(axum::Json(serde_json::json!({
        "resourceType": stmt.resource_type,
        "status": stmt.status,
        "date": stmt.date,
        "kind": stmt.kind,
        "fhirVersion": stmt.fhir_version,
        "format": stmt.format,
        "rest": stmt.rest,
    })))
}
  • Step 2: 验证编译

Run: cargo check -p erp-health Expected: 编译通过

  • Step 3: Commit
git add crates/erp-health/src/fhir/handler.rs
git commit -m "feat(health): FHIR CapabilityStatement endpoint"

Task 3: FHIR 路由注册

Files:

  • Modify: crates/erp-health/src/module.rs

  • Step 1: 在 HealthModule 添加 fhir_routes 方法

参考现有 protected_routes() 模式,添加独立的 FHIR 路由组:

// 在 impl HealthModule 中添加
pub fn fhir_routes<S>() -> axum::Router<S>
where
    crate::state::HealthState: axum::extract::FromRef<S>,
    S: Clone + Send + Sync + 'static,
{
    use crate::fhir::handler;

    axum::Router::new()
        .route("/fhir/R4/metadata", axum::routing::get(handler::capability_statement))
        // 后续 Task 逐一添加:
        // .route("/fhir/R4/Patient", ...)
        // .route("/fhir/R4/Patient/{id}", ...)
        // .route("/fhir/R4/Observation", ...)
        // ...
}
  • Step 2: 在 erp-server main.rs 挂载 FHIR 路由

crates/erp-server/src/main.rs 的路由组装处(搜索 erp_health::HealthModule::protected_routes()),在 protected router 上追加:

.merge(erp_health::HealthModule::fhir_routes())

注意FHIR 路由暂时挂在 protected router 下(使用 JWT 认证Plan 3 会将其迁移到独立的 OAuth2 中间件。

  • Step 3: 验证编译

Run: cargo check -p erp-server Expected: 编译通过

  • Step 4: 验证 CapabilityStatement 可访问

启动后端:cd crates/erp-server && cargo run 访问:http://localhost:3000/fhir/R4/metadata Expected: 返回 FHIR CapabilityStatement JSON

  • Step 5: Commit
git add crates/erp-health/src/module.rs crates/erp-server/src/main.rs
git commit -m "feat(server): 挂载 FHIR R4 路由组 + CapabilityStatement"

Chunk 2: Patient + Observation 转换

Task 4: HMS Patient → FHIR Patient 转换

Files:

  • Modify: crates/erp-health/src/fhir/converter.rs

  • Step 1: 编写 Patient 转换测试

converter.rs 底部添加:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_patient_to_fhir_basic() {
        let patient = crate::entity::patient::Model {
            id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
            tenant_id: uuid::Uuid::new_v4(),
            name: "张三".into(),
            gender: Some("male".into()),
            birth_date: Some(chrono::NaiveDate::from_ymd_opt(1968, 5, 15).unwrap()),
            // ... 其余字段用 Default 或手动填充
            ..Default::default() // 如果 Model 实现了 Default
        };
        let fhir = patient_to_fhir(&patient);
        assert_eq!(fhir["resourceType"], "Patient");
        assert_eq!(fhir["id"], "00000000-0000-0000-0000-000000000001");
        assert_eq!(fhir["gender"], "male");
        assert_eq!(fhir["birthDate"], "1968-05-15");
    }
}

注意:如果 Patient Model 未实现 Default需要手动构造。测试中只需验证关键字段的转换正确性。

  • Step 2: 实现 patient_to_fhir 转换函数
use crate::entity::{patient, device_readings, patient_devices, alerts,
    appointment, doctor_profile, consultation_session};

/// HMS Patient → FHIR Patient (JSON Value)
pub fn patient_to_fhir(p: &patient::Model) -> serde_json::Value {
    let mut human_name = serde_json::json!({
        "family": p.name,
        "text": p.name,
    });

    let mut result = serde_json::json!({
        "resourceType": "Patient",
        "id": p.id.to_string(),
        "meta": {
            "source": "hms",
            "lastUpdated": p.updated_at.to_rfc3339(),
        },
    });

    if let Some(ref gender) = p.gender {
        result["gender"] = serde_json::json!(match gender.as_str() {
            "male" | "M" => "male",
            "female" | "F" => "female",
            "other" => "other",
            _ => "unknown",
        });
    }

    if let Some(bd) = p.birth_date {
        result["birthDate"] = serde_json::json!(bd.to_string());
    }

    result["name"] = serde_json::json!([human_name]);

    if let Some(ref id_number) = p.id_number {
        result["identifier"] = serde_json::json!([{
            "system": "urn:oid:2.16.156.10011.1.3",
            "value": id_number,
        }]);
    }

    result
}
  • Step 3: 验证测试通过

Run: cargo test -p erp-health test_patient_to_fhir Expected: PASS

  • Step 4: Commit
git add crates/erp-health/src/fhir/converter.rs
git commit -m "feat(health): HMS Patient → FHIR Patient 转换函数"

Task 5: HMS DeviceReading → FHIR Observation 转换

这是最复杂的转换,因为一个 device_readings 行可能包含多值(如血压含收缩压/舒张压),需要拆分为多个 FHIR Observation。

Files:

  • Modify: crates/erp-health/src/fhir/converter.rs

  • Step 1: 编写 Observation 转换测试

#[test]
fn test_heart_rate_to_fhir_observation() {
    let reading = crate::entity::device_readings::Model {
        id: uuid::Uuid::new_v4(),
        tenant_id: uuid::Uuid::new_v4(),
        patient_id: uuid::Uuid::new_v4(),
        device_id: Some("band-001".into()),
        device_type: "heart_rate".into(),
        metric: None,
        device_model: Some("Mi Band 8".into()),
        raw_value: serde_json::json!({"heart_rate": 78}),
        measured_at: chrono::Utc::now(),
        created_at: chrono::Utc::now(),
        deleted_at: None,
    };

    let observations = device_reading_to_fhir_observations(&reading);
    assert_eq!(observations.len(), 1);
    let obs = &observations[0];
    assert_eq!(obs["resourceType"], "Observation");
    assert_eq!(obs["code"]["coding"][0]["code"], "8867-4");
    assert_eq!(obs["valueQuantity"]["value"], 78);
}

#[test]
fn test_blood_pressure_to_fhir_observations() {
    // 血压应拆分为 2-3 个 Observation收缩压 + 舒张压 + 可选心率)
    let reading = crate::entity::device_readings::Model {
        id: uuid::Uuid::new_v4(),
        tenant_id: uuid::Uuid::new_v4(),
        patient_id: uuid::Uuid::new_v4(),
        device_id: Some("bp-001".into()),
        device_type: "blood_pressure".into(),
        metric: None,
        device_model: Some("Omron HEM-7322".into()),
        raw_value: serde_json::json!({"systolic": 135, "diastolic": 88, "pulse_rate": 72}),
        measured_at: chrono::Utc::now(),
        created_at: chrono::Utc::now(),
        deleted_at: None,
    };

    let observations = device_reading_to_fhir_observations(&reading);
    assert!(observations.len() >= 2); // 至少收缩压 + 舒张压
}
  • Step 2: 实现 device_reading_to_fhir_observations
/// HMS DeviceReading → FHIR Observation可能拆分为多个
pub fn device_reading_to_fhir_observations(r: &device_readings::Model) -> Vec<serde_json::Value> {
    let mut results = Vec::new();
    let patient_ref = serde_json::json!({"reference": format!("Patient/{}", r.patient_id)});
    let device_ref = r.device_id.as_ref().map(|d|
        serde_json::json!({"reference": format!("Device/{}", d)})
    );
    let measured = r.measured_at.to_rfc3339();
    let category = device_type_to_category(&r.device_type);

    let category_json = serde_json::json!([{
        "coding": [{
            "system": "http://terminology.hl7.org/CodeSystem/observation-category",
            "code": category,
        }]
    }]);

    match r.device_type.as_str() {
        "blood_pressure" => {
            // 拆分为收缩压 + 舒张压
            let sys = r.raw_value.get("systolic").and_then(|v| v.as_f64());
            let dia = r.raw_value.get("diastolic").and_then(|v| v.as_f64());

            if let Some(val) = sys {
                results.push(make_observation(
                    &r.id, "8480-6", "Systolic blood pressure",
                    category_json.clone(), &patient_ref, device_ref.as_ref(),
                    &measured, val, "mmHg", "mm[Hg]",
                ));
            }
            if let Some(val) = dia {
                results.push(make_observation(
                    &r.id, "8462-4", "Diastolic blood pressure",
                    category_json, &patient_ref, device_ref.as_ref(),
                    &measured, val, "mmHg", "mm[Hg]",
                ));
            }
        }
        _ => {
            // 单值设备类型
            let (loinc_code, loinc_display) = device_type_to_loinc(&r.device_type)
                .unwrap_or(("unknown", "Unknown"));
            let (unit_display, unit_code) = device_type_to_unit(&r.device_type);

            // 从 raw_value 中提取主值
            let val = extract_main_value(&r.device_type, &r.raw_value);
            if let Some(v) = val {
                results.push(make_observation(
                    &r.id, loinc_code, loinc_display,
                    category_json, &patient_ref, device_ref.as_ref(),
                    &measured, v, unit_display, unit_code,
                ));
            }
        }
    }

    results
}

fn make_observation(
    reading_id: &uuid::Uuid, code: &str, display: &str,
    category: serde_json::Value, subject: &serde_json::Value,
    device: Option<&serde_json::Value>, effective: &str,
    value: f64, unit_display: &str, unit_code: &str,
) -> serde_json::Value {
    let mut obs = serde_json::json!({
        "resourceType": "Observation",
        "id": reading_id.to_string(),
        "status": "final",
        "category": category,
        "code": {
            "coding": [{
                "system": "http://loinc.org",
                "code": code,
                "display": display,
            }]
        },
        "subject": subject,
        "effectiveDateTime": effective,
        "valueQuantity": {
            "value": value,
            "unit": unit_display,
            "system": "http://unitsofmeasure.org",
            "code": unit_code,
        },
        "meta": { "source": "hms-device" },
    });
    if let Some(d) = device {
        obs["device"] = d.clone();
    }
    obs
}

fn extract_main_value(device_type: &str, raw: &serde_json::Value) -> Option<f64> {
    match device_type {
        "heart_rate" => raw.get("heart_rate").and_then(|v| v.as_f64()),
        "blood_oxygen" => raw.get("blood_oxygen").and_then(|v| v.as_f64()),
        "temperature" => raw.get("temperature").and_then(|v| v.as_f64()),
        "blood_glucose" => raw.get("blood_glucose").and_then(|v| v.as_f64()),
        "steps" => raw.get("steps").and_then(|v| v.as_f64()),
        "sleep" => raw.get("sleep_duration").and_then(|v| v.as_f64()),
        "stress" => raw.get("stress_level").and_then(|v| v.as_f64()),
        // raw_value 自身可能是数值
        _ => raw.as_f64(),
    }
}
  • Step 3: 验证测试通过

Run: cargo test -p erp-health test_heart_rate test_blood_pressure Expected: PASS

  • Step 4: Commit
git add crates/erp-health/src/fhir/converter.rs
git commit -m "feat(health): DeviceReading → FHIR Observation 转换(含血压拆分)"

Task 6: Patient + Observation FHIR Handler + 路由

Files:

  • Modify: crates/erp-health/src/fhir/handler.rs

  • Modify: crates/erp-health/src/module.rs

  • Step 1: 实现 Patient FHIR handler

use axum::extract::{Path, Query, State};
use erp_core::types::{ApiResponse, TenantContext};
use erp_core::rbac::require_permission;
use sea_orm::*;

pub async fn search_patients<S>(
    State(state): State<crate::state::HealthState>,
    Extension(ctx): Extension<TenantContext>,
    Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, erp_core::error::AppError>
where
    crate::state::HealthState: axum::extract::FromRef<S>,
    S: Clone + Send + Sync + 'static,
{
    require_permission(&ctx, "health.patients.list")?;

    let mut query = crate::entity::patient::Entity::find()
        .filter(crate::entity::patient::Column::TenantId.eq(ctx.tenant_id))
        .filter(crate::entity::patient::Column::DeletedAt.is_null());

    if let Some(ref id) = params.id {
        query = query.filter(crate::entity::patient::Column::Id.eq(uuid::Uuid::parse_str(id).map_err(|_| {
            erp_core::error::AppError::Validation("Invalid patient id".into())
        })?));
    }
    if let Some(ref name) = params.name {
        query = query.filter(crate::entity::patient::Column::Name.contains(name));
    }

    let limit = params.count.unwrap_or(20).min(100) as u64;
    let offset = params.offset.unwrap_or(0) as u64;
    let patients = query
        .limit(limit)
        .offset(offset)
        .all(&state.db)
        .await?;

    let entries: Vec<serde_json::Value> = patients.iter()
        .map(|p| converter::patient_to_fhir(p))
        .map(|r| serde_json::json!({"resource": r}))
        .collect();

    let bundle = serde_json::json!({
        "resourceType": "Bundle",
        "type": "searchset",
        "total": entries.len(),
        "entry": entries,
    });

    Ok(axum::Json(bundle))
}

pub async fn get_patient<S>(
    State(state): State<crate::state::HealthState>,
    Extension(ctx): Extension<TenantContext>,
    Path(id): Path<uuid::Uuid>,
) -> Result<impl IntoResponse, erp_core::error::AppError>
where
    crate::state::HealthState: axum::extract::FromRef<S>,
    S: Clone + Send + Sync + 'static,
{
    require_permission(&ctx, "health.patients.list")?;

    let patient = crate::entity::patient::Entity::find_by_id(id)
        .one(&state.db)
        .await?
        .ok_or_else(|| erp_core::error::AppError::NotFound("Patient not found".into()))?;

    Ok(axum::Json(converter::patient_to_fhir(&patient)))
}
  • Step 2: 实现 Observation FHIR handler
pub async fn search_observations<S>(
    State(state): State<crate::state::HealthState>,
    Extension(ctx): Extension<TenantContext>,
    Query(params): Query<FhirSearchParams>,
) -> Result<impl IntoResponse, erp_core::error::AppError>
where
    crate::state::HealthState: axum::extract::FromRef<S>,
    S: Clone + Send + Sync + 'static,
{
    require_permission(&ctx, "health.device-readings.list")?;

    let mut query = crate::entity::device_readings::Entity::find()
        .filter(crate::entity::device_readings::Column::TenantId.eq(ctx.tenant_id))
        .filter(crate::entity::device_readings::Column::DeletedAt.is_null());

    if let Some(ref patient_id) = params.patient {
        query = query.filter(
            crate::entity::device_readings::Column::PatientId
                .eq(uuid::Uuid::parse_str(patient_id).map_err(|_| {
                    erp_core::error::AppError::Validation("Invalid patient id".into())
                })?)
        );
    }
    if let Some(ref code) = params.code {
        // code 可以是 LOINC code需要映射回 device_type
        let device_type = loinc_to_device_type(code);
        if let Some(dt) = device_type {
            query = query.filter(crate::entity::device_readings::Column::DeviceType.eq(dt));
        }
    }
    if let Some(ref category) = params.category {
        // category 映射vital-signs → heart_rate/blood_oxygen/blood_pressure/temperature
        let types = category_to_device_types(category);
        query = query.filter(
            crate::entity::device_readings::Column::DeviceType.is_in(types)
        );
    }

    // date 参数:支持 gt/lt/ge/le 前缀
    if let Some(ref date) = params.date {
        if let Some(after) = date.strip_prefix("gt") {
            if let Ok(dt) = after.parse::<chrono::DateTime<chrono::Utc>>() {
                query = query.filter(
                    crate::entity::device_readings::Column::MeasuredAt.gt(dt)
                );
            }
        } else if let Ok(dt) = date.parse::<chrono::DateTime<chrono::Utc>>() {
            // 精确匹配当天
            let start = dt.date_naive().and_hms_opt(0,0,0).unwrap().and_utc();
            let end = dt.date_naive().and_hms_opt(23,59,59).unwrap().and_utc();
            query = query.filter(
                crate::entity::device_readings::Column::MeasuredAt.between(start, end)
            );
        }
    }

    let limit = params.count.unwrap_or(50).min(200) as u64;
    let readings = query.limit(limit).all(&state.db).await?;

    let mut entries = Vec::new();
    for reading in &readings {
        for obs in converter::device_reading_to_fhir_observations(reading) {
            entries.push(serde_json::json!({"resource": obs}));
        }
    }

    let bundle = serde_json::json!({
        "resourceType": "Bundle",
        "type": "searchset",
        "total": entries.len(),
        "entry": entries,
    });

    Ok(axum::Json(bundle))
}

/// LOINC code → device_type 反向映射
fn loinc_to_device_type(loinc: &str) -> Option<&'static str> {
    match loinc {
        "8867-4" => Some("heart_rate"),
        "2708-6" => Some("blood_oxygen"),
        "85354-9" | "8480-6" | "8462-4" => Some("blood_pressure"),
        "2339-0" => Some("blood_glucose"),
        "8310-5" => Some("temperature"),
        "55423-8" => Some("steps"),
        "93832-4" => Some("sleep"),
        _ => None,
    }
}

/// FHIR category → device_type 列表
fn category_to_device_types(category: &str) -> Vec<&'static str> {
    match category {
        "vital-signs" => vec!["heart_rate", "blood_oxygen", "blood_pressure", "temperature"],
        "laboratory" => vec!["blood_glucose"],
        "activity" => vec!["steps", "sleep", "stress"],
        _ => vec![],
    }
}
  • Step 3: 注册 Patient + Observation 路由

module.rsfhir_routes() 中追加:

.route("/fhir/R4/Patient", axum::routing::get(handler::search_patients))
.route("/fhir/R4/Patient/{id}", axum::routing::get(handler::get_patient))
.route("/fhir/R4/Observation", axum::routing::get(handler::search_observations))
  • Step 4: 验证编译

Run: cargo check -p erp-health Expected: 编译通过

  • Step 5: 功能验证

启动后端,测试:

# 搜索患者
curl -H "Authorization: Bearer <token>" http://localhost:3000/fhir/R4/Patient?name=# 获取单个患者
curl -H "Authorization: Bearer <token>" http://localhost:3000/fhir/R4/Patient/{id}

# 搜索观测数据
curl -H "Authorization: Bearer <token>" "http://localhost:3000/fhir/R4/Observation?patient={id}&category=vital-signs"
  • Step 6: Commit
git add crates/erp-health/src/fhir/handler.rs crates/erp-health/src/module.rs
git commit -m "feat(health): FHIR Patient + Observation 搜索/读取端点"

Chunk 3: 其余 6 个 FHIR 资源

Task 7: Device + Practitioner + Appointment 转换 + Handler

这三个资源转换相对简单,一次实现。

Files:

  • Modify: crates/erp-health/src/fhir/converter.rs

  • Modify: crates/erp-health/src/fhir/handler.rs

  • Modify: crates/erp-health/src/module.rs

  • Step 1: 实现 3 个转换函数

converter.rs 中添加:

/// HMS PatientDevice → FHIR Device
pub fn patient_device_to_fhir(d: &patient_devices::Model) -> serde_json::Value {
    let mut device = serde_json::json!({
        "resourceType": "Device",
        "id": d.id.to_string(),
        "status": match d.device_type.as_deref() {
            Some("active") => "active",
            _ => "unknown",
        },
        "meta": { "source": "hms-device" },
    });

    let mut identifiers = Vec::new();
    identifiers.push(serde_json::json!({
        "system": "urn:hms:device-id",
        "value": d.device_id,
    }));
    device["identifier"] = serde_json::json!(identifiers);

    if let Some(ref model) = d.device_model {
        device["displayName"] = serde_json::json!(model);
    }
    if let Some(ref dt) = d.device_type {
        device["type"] = serde_json::json!({
            "coding": [{
                "system": "urn:hms:device-type",
                "code": dt,
            }]
        });
    }

    device
}

/// HMS DoctorProfile → FHIR Practitioner
pub fn doctor_to_fhir(d: &doctor_profile::Model) -> serde_json::Value {
    let mut practitioner = serde_json::json!({
        "resourceType": "Practitioner",
        "id": d.id.to_string(),
        "name": [{
            "text": d.name,
        }],
        "meta": { "source": "hms-practitioner" },
    });

    let mut qualifications = Vec::new();
    if let Some(ref title) = d.title {
        qualifications.push(serde_json::json!({
            "code": {
                "coding": [{
                    "system": "urn:hms:doctor-title",
                    "display": title,
                }]
            }
        }));
    }
    if let Some(ref dept) = d.department {
        qualifications.push(serde_json::json!({
            "code": {
                "coding": [{
                    "system": "urn:hms:department",
                    "display": dept,
                }]
            }
        }));
    }
    if !qualifications.is_empty() {
        practitioner["qualification"] = serde_json::json!(qualifications);
    }

    if let Some(ref specialty) = d.specialty {
        practitioner["specialty"] = serde_json::json!([{
            "coding": [{
                "system": "urn:hms:specialty",
                "display": specialty,
            }]
        }]);
    }

    practitioner
}

/// HMS Appointment → FHIR Appointment
pub fn appointment_to_fhir(a: &appointment::Model) -> serde_json::Value {
    let fhir_status = match a.status.as_str() {
        "confirmed" | "completed" => a.status.as_str(),
        "pending" => "proposed",
        "cancelled" => "cancelled",
        _ => "booked",
    };

    let mut participants = vec![
        serde_json::json!({
            "actor": {"reference": format!("Patient/{}", a.patient_id)},
            "status": "accepted",
        }),
    ];
    if let Some(ref doctor_id) = a.doctor_id {
        participants.push(serde_json::json!({
            "actor": {"reference": format!("Practitioner/{}", doctor_id)},
            "status": "accepted",
        }));
    }

    let start = format!("{}T{}", a.appointment_date, a.start_time);
    let end = format!("{}T{}", a.appointment_date, a.end_time);

    serde_json::json!({
        "resourceType": "Appointment",
        "id": a.id.to_string(),
        "status": fhir_status,
        "participant": participants,
        "start": start,
        "end": end,
        "meta": { "source": "hms-appointment" },
    })
}
  • Step 2: 实现 3 个 Handler

handler.rs 中添加 Device/Practitioner/Appointment 的 search + get handler。模式与 Task 6 完全一致:查询 Entity → 转换 → 返回 Bundle。

// Device
pub async fn search_devices<S>(/* 同 Task 6 模式 */) -> Result<impl IntoResponse, AppError> {
    // 查询 patient_devices调用 converter::patient_device_to_fhir
}

pub async fn get_device<S>(/* Path(id) */) -> Result<impl IntoResponse, AppError> {
    // 查询单个 patient_device
}

// Practitioner
pub async fn search_practitioners<S>(/* 同上 */) -> Result<impl IntoResponse, AppError> {
    // 查询 doctor_profile调用 converter::doctor_to_fhir
}

pub async fn get_practitioner<S>(/* Path(id) */) -> Result<impl IntoResponse, AppError> {}

// Appointment
pub async fn search_appointments<S>(/* 同上 */) -> Result<impl IntoResponse, AppError> {
    // 查询 appointment调用 converter::appointment_to_fhir
}

pub async fn get_appointment<S>(/* Path(id) */) -> Result<impl IntoResponse, AppError> {}
  • Step 3: 注册路由

fhir_routes() 追加:

.route("/fhir/R4/Device", axum::routing::get(handler::search_devices))
.route("/fhir/R4/Device/{id}", axum::routing::get(handler::get_device))
.route("/fhir/R4/Practitioner", axum::routing::get(handler::search_practitioners))
.route("/fhir/R4/Practitioner/{id}", axum::routing::get(handler::get_practitioner))
.route("/fhir/R4/Appointment", axum::routing::get(handler::search_appointments))
.route("/fhir/R4/Appointment/{id}", axum::routing::get(handler::get_appointment))
  • Step 4: 验证编译

Run: cargo check -p erp-health Expected: 编译通过

  • Step 5: Commit
git add crates/erp-health/src/fhir/
git commit -m "feat(health): FHIR Device + Practitioner + Appointment 端点"

Task 8: DiagnosticReport + Encounter + Task 转换 + Handler

Files:

  • Modify: crates/erp-health/src/fhir/converter.rs

  • Modify: crates/erp-health/src/fhir/handler.rs

  • Modify: crates/erp-health/src/module.rs

  • Step 1: 实现 3 个转换函数

/// HMS LabReport → FHIR DiagnosticReport
/// 注:需要读取 lab_report entity 字段,此处为示意
pub fn lab_report_to_fhir(
    id: &uuid::Uuid,
    patient_id: &uuid::Uuid,
    report_type: &str,
    status: &str,
    created_at: &chrono::DateTime<chrono::Utc>,
) -> serde_json::Value {
    let fhir_status = match status {
        "final" | "completed" => "final",
        "preliminary" => "preliminary",
        _ => "registered",
    };

    serde_json::json!({
        "resourceType": "DiagnosticReport",
        "id": id.to_string(),
        "status": fhir_status,
        "code": {
            "coding": [{
                "system": "urn:hms:report-type",
                "code": report_type,
            }]
        },
        "subject": {"reference": format!("Patient/{}", patient_id)},
        "effectiveDateTime": created_at.to_rfc3339(),
        "meta": { "source": "hms-diagnostic-report" },
    })
}

/// HMS ConsultationSession → FHIR Encounter
pub fn consultation_to_fhir(c: &consultation_session::Model) -> serde_json::Value {
    let fhir_status = match c.status.as_str() {
        "active" => "in-progress",
        "closed" => "finished",
        _ => "planned",
    };

    serde_json::json!({
        "resourceType": "Encounter",
        "id": c.id.to_string(),
        "status": fhir_status,
        "class": {
            "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
            "code": match c.consultation_type.as_str() {
                "online" => "VR",
                "offline" => "AMB",
                _ => "AMB",
            },
        },
        "subject": {"reference": format!("Patient/{}", c.patient_id)},
        "meta": { "source": "hms-encounter" },
    })
}

/// HMS FollowUpTask → FHIR Task
pub fn follow_up_to_fhir(
    id: &uuid::Uuid,
    patient_id: &uuid::Uuid,
    status: &str,
    title: &str,
) -> serde_json::Value {
    let fhir_status = match status {
        "pending" => "requested",
        "in_progress" => "in-progress",
        "completed" => "completed",
        "cancelled" => "cancelled",
        _ => "requested",
    };

    serde_json::json!({
        "resourceType": "Task",
        "id": id.to_string(),
        "status": fhir_status,
        "intent": "plan",
        "focus": {
            "display": title,
        },
        "for": {"reference": format!("Patient/{}", patient_id)},
        "meta": { "source": "hms-task" },
    })
}
  • Step 2: 实现 Handler + 注册路由

同 Task 7 模式,查询对应 Entity → 转换 → Bundle。

路由:

.route("/fhir/R4/DiagnosticReport", axum::routing::get(handler::search_diagnostic_reports))
.route("/fhir/R4/DiagnosticReport/{id}", axum::routing::get(handler::get_diagnostic_report))
.route("/fhir/R4/Encounter", axum::routing::get(handler::search_encounters))
.route("/fhir/R4/Encounter/{id}", axum::routing::get(handler::get_encounter))
.route("/fhir/R4/Task", axum::routing::get(handler::search_tasks))
.route("/fhir/R4/Task/{id}", axum::routing::get(handler::get_task))
  • Step 3: 验证编译

Run: cargo check -p erp-health Expected: 编译通过

  • Step 4: Commit
git add crates/erp-health/src/fhir/
git commit -m "feat(health): FHIR DiagnosticReport + Encounter + Task 端点"

Chunk 4: $everything 操作

Task 9: Patient $everything 操作

Files:

  • Modify: crates/erp-health/src/fhir/handler.rs

  • Modify: crates/erp-health/src/module.rs

  • Step 1: 实现 patient_everything handler

/// GET /fhir/R4/Patient/{id}/$everything
/// 一次请求返回患者所有关联的 FHIR 资源
pub async fn patient_everything<S>(
    State(state): State<crate::state::HealthState>,
    Extension(ctx): Extension<TenantContext>,
    Path(id): Path<uuid::Uuid>,
) -> Result<impl IntoResponse, erp_core::error::AppError>
where
    crate::state::HealthState: axum::extract::FromRef<S>,
    S: Clone + Send + Sync + 'static,
{
    require_permission(&ctx, "health.patients.list")?;

    // 验证患者存在且属于当前租户
    let patient = crate::entity::patient::Entity::find_by_id(id)
        .one(&state.db)
        .await?
        .ok_or_else(|| erp_core::error::AppError::NotFound("Patient not found".into()))?;

    if patient.tenant_id != ctx.tenant_id {
        return Err(erp_core::error::AppError::Forbidden("Access denied".into()));
    }

    let mut entries = Vec::new();

    // 1. Patient 本身
    entries.push(serde_json::json!({
        "resource": converter::patient_to_fhir(&patient),
        "fullUrl": format!("https://hms.local/fhir/R4/Patient/{}", id),
    }));

    // 2. Observations设备读数
    let readings = crate::entity::device_readings::Entity::find()
        .filter(crate::entity::device_readings::Column::PatientId.eq(id))
        .filter(crate::entity::device_readings::Column::DeletedAt.is_null())
        .limit(200) // $everything 默认限制
        .all(&state.db)
        .await?;
    for r in &readings {
        for obs in converter::device_reading_to_fhir_observations(r) {
            entries.push(serde_json::json!({"resource": obs}));
        }
    }

    // 3. Devices
    let devices = crate::entity::patient_devices::Entity::find()
        .filter(crate::entity::patient_devices::Column::PatientId.eq(id))
        .filter(crate::entity::patient_devices::Column::DeletedAt.is_null())
        .all(&state.db)
        .await?;
    for d in &devices {
        entries.push(serde_json::json!({"resource": converter::patient_device_to_fhir(d)}));
    }

    // 4. Encounters咨询
    let consultations = crate::entity::consultation_session::Entity::find()
        .filter(crate::entity::consultation_session::Column::PatientId.eq(id))
        .filter(crate::entity::consultation_session::Column::DeletedAt.is_null())
        .all(&state.db)
        .await?;
    for c in &consultations {
        entries.push(serde_json::json!({"resource": converter::consultation_to_fhir(c)}));
    }

    // 5. Appointments
    let appointments = crate::entity::appointment::Entity::find()
        .filter(crate::entity::appointment::Column::PatientId.eq(id))
        .filter(crate::entity::appointment::Column::DeletedAt.is_null())
        .all(&state.db)
        .await?;
    for a in &appointments {
        entries.push(serde_json::json!({"resource": converter::appointment_to_fhir(a)}));
    }

    // 6. Tasks随访任务
    let tasks = crate::entity::follow_up_task::Entity::find()
        .filter(crate::entity::follow_up_task::Column::PatientId.eq(id))
        .filter(crate::entity::follow_up_task::Column::DeletedAt.is_null())
        .limit(50)
        .all(&state.db)
        .await?;
    for t in &tasks {
        entries.push(serde_json::json!({
            "resource": converter::follow_up_to_fhir(
                &t.id, &t.patient_id, &t.status,
                t.title.as_deref().unwrap_or("Follow-up Task"),
            )
        }));
    }

    let bundle = serde_json::json!({
        "resourceType": "Bundle",
        "type": "collection",
        "total": entries.len(),
        "entry": entries,
    });

    Ok(axum::Json(bundle))
}
  • Step 2: 注册 $everything 路由
.route("/fhir/R4/Patient/{id}/$everything", axum::routing::get(handler::patient_everything))
  • Step 3: 验证编译

Run: cargo check -p erp-health Expected: 编译通过

  • Step 4: 功能验证
curl -H "Authorization: Bearer <token>" \
  http://localhost:3000/fhir/R4/Patient/{id}/\$everything

Expected: 返回包含 Patient + Observation + Device + Encounter + Appointment + Task 的 Bundle

  • Step 5: 运行全量测试

Run: cargo test --workspace Expected: 全部通过

  • Step 6: Commit
git add crates/erp-health/src/fhir/ crates/erp-health/src/module.rs
git commit -m "feat(health): FHIR \$everything 操作 — 患者全景数据一次返回"

验证清单

  • cargo check --workspace — 编译通过
  • cargo test --workspace — 全部测试通过
  • /fhir/R4/metadata — 返回 CapabilityStatement
  • /fhir/R4/Patient?name=张 — 搜索患者返回 FHIR Bundle
  • /fhir/R4/Patient/{id} — 读取单个患者返回 FHIR Patient
  • /fhir/R4/Observation?patient={id}&category=vital-signs — 搜索体征数据
  • /fhir/R4/Device — 搜索设备
  • /fhir/R4/Practitioner — 搜索医护人员
  • /fhir/R4/Appointment — 搜索预约
  • /fhir/R4/Patient/{id}/$everything — 患者全景数据
  • git push 推送到远程仓库