4 Chunk 9 Task:FHIR 基础类型 + CapabilityStatement + Patient/Observation 转换 + 6 资源端点 + $everything 操作。 分步 TDD 流程,每步有具体代码和验证命令。
44 KiB
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 1(vital_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.rs 和 handler.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.rs 的 fhir_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推送到远程仓库