diff --git a/docs/superpowers/plans/2026-05-04-iot-fhir-v1-plan2-fhir-api.md b/docs/superpowers/plans/2026-05-04-iot-fhir-v1-plan2-fhir-api.md new file mode 100644 index 0000000..511dce2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-iot-fhir-v1-plan2-fhir-api.md @@ -0,0 +1,1414 @@ +# 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 模块目录** + +```bash +mkdir -p crates/erp-health/src/fhir +``` + +- [ ] **Step 2: 创建 FHIR 基础类型** + +```rust +// crates/erp-health/src/fhir/types.rs +use serde::Serialize; + +/// FHIR 资源通用包装 +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum FhirResource { + Patient(Box), + Observation(Box), + Device(Box), + DiagnosticReport(Box), + Encounter(Box), + Practitioner(Box), + Appointment(Box), + Task(Box), + CapabilityStatement(Box), + Bundle(Box), + OperationOutcome(Box), +} + +/// FHIR Bundle(搜索结果集) +#[derive(Debug, Serialize)] +pub struct BundleResource { + pub resource_type: String, + #[serde(rename = "type")] + pub bundle_type: String, + pub total: Option, + pub entry: Vec, +} + +#[derive(Debug, Serialize)] +pub struct BundleEntry { + pub resource: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub full_url: Option, +} + +/// FHIR 搜索参数 +#[derive(Debug, serde::Deserialize, utoipa::IntoParams)] +pub struct FhirSearchParams { + #[serde(rename = "_id")] + pub id: Option, + #[serde(rename = "_count")] + pub count: Option, + #[serde(rename = "_offset")] + pub offset: Option, + pub patient: Option, + pub category: Option, + pub code: Option, + pub date: Option, + pub name: Option, + pub identifier: Option, + pub status: Option, +} + +/// 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, +} + +#[derive(Debug, Serialize)] +pub struct OperationOutcomeIssue { + pub severity: String, + pub code: String, + pub diagnostics: Option, +} + +/// 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, + pub rest: Vec, +} + +// ===== 各资源的占位 struct,后续 Task 逐一实现 ===== + +#[derive(Debug, Serialize)] +pub struct PatientResource { + pub resource_type: String, + pub id: String, + pub name: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub gender: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub birth_date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identifier: Option>, + pub meta: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct ObservationResource { + pub resource_type: String, + pub id: String, + pub status: String, + pub category: Vec, + pub code: serde_json::Value, + pub subject: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub device: Option, + #[serde(rename = "effectiveDateTime")] + pub effective_date_time: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_quantity: Option, + 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>, + #[serde(rename = "displayName")] + pub display_name: Option, + 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, + 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(skip_serializing_if = "Option::is_none")] + pub qualification: Option>, + 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(rename = "start")] + pub start: Option, + #[serde(rename = "end")] + pub end: Option, + 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** + +```rust +// 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)中添加: +```rust +pub mod fhir; +``` + +- [ ] **Step 5: 创建占位文件以通过编译** + +```bash +touch crates/erp-health/src/fhir/converter.rs +touch crates/erp-health/src/fhir/handler.rs +``` + +在 `converter.rs` 和 `handler.rs` 中各放一行注释占位: +```rust +// converter.rs — HMS → FHIR 转换函数 +``` +```rust +// handler.rs — FHIR R4 Axum handlers +``` + +- [ ] **Step 6: 验证编译** + +Run: `cargo check -p erp-health` +Expected: 编译通过 + +- [ ] **Step 7: Commit** + +```bash +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** + +```rust +// 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( +) -> Result +where + crate::state::HealthState: axum::extract::FromRef, + 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** + +```bash +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 路由组: + +```rust +// 在 impl HealthModule 中添加 +pub fn fhir_routes() -> axum::Router +where + crate::state::HealthState: axum::extract::FromRef, + 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 上追加: + +```rust +.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** + +```bash +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` 底部添加: + +```rust +#[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 转换函数** + +```rust +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** + +```bash +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 转换测试** + +```rust +#[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** + +```rust +/// HMS DeviceReading → FHIR Observation(可能拆分为多个) +pub fn device_reading_to_fhir_observations(r: &device_readings::Model) -> Vec { + 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 { + 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** + +```bash +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** + +```rust +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( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result +where + crate::state::HealthState: axum::extract::FromRef, + 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 = 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result +where + crate::state::HealthState: axum::extract::FromRef, + 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** + +```rust +pub async fn search_observations( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result +where + crate::state::HealthState: axum::extract::FromRef, + 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::>() { + query = query.filter( + crate::entity::device_readings::Column::MeasuredAt.gt(dt) + ); + } + } else if let Ok(dt) = date.parse::>() { + // 精确匹配当天 + 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()` 中追加: + +```rust +.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: 功能验证** + +启动后端,测试: +```bash +# 搜索患者 +curl -H "Authorization: Bearer " http://localhost:3000/fhir/R4/Patient?name=张 + +# 获取单个患者 +curl -H "Authorization: Bearer " http://localhost:3000/fhir/R4/Patient/{id} + +# 搜索观测数据 +curl -H "Authorization: Bearer " "http://localhost:3000/fhir/R4/Observation?patient={id}&category=vital-signs" +``` + +- [ ] **Step 6: Commit** + +```bash +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` 中添加: + +```rust +/// 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。 + +```rust +// Device +pub async fn search_devices(/* 同 Task 6 模式 */) -> Result { + // 查询 patient_devices,调用 converter::patient_device_to_fhir +} + +pub async fn get_device(/* Path(id) */) -> Result { + // 查询单个 patient_device +} + +// Practitioner +pub async fn search_practitioners(/* 同上 */) -> Result { + // 查询 doctor_profile,调用 converter::doctor_to_fhir +} + +pub async fn get_practitioner(/* Path(id) */) -> Result {} + +// Appointment +pub async fn search_appointments(/* 同上 */) -> Result { + // 查询 appointment,调用 converter::appointment_to_fhir +} + +pub async fn get_appointment(/* Path(id) */) -> Result {} +``` + +- [ ] **Step 3: 注册路由** + +在 `fhir_routes()` 追加: +```rust +.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** + +```bash +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 个转换函数** + +```rust +/// 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, +) -> 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。 + +路由: +```rust +.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** + +```bash +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** + +```rust +/// GET /fhir/R4/Patient/{id}/$everything +/// 一次请求返回患者所有关联的 FHIR 资源 +pub async fn patient_everything( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result +where + crate::state::HealthState: axum::extract::FromRef, + 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 路由** + +```rust +.route("/fhir/R4/Patient/{id}/$everything", axum::routing::get(handler::patient_everything)) +``` + +- [ ] **Step 3: 验证编译** + +Run: `cargo check -p erp-health` +Expected: 编译通过 + +- [ ] **Step 4: 功能验证** + +```bash +curl -H "Authorization: Bearer " \ + 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** + +```bash +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` 推送到远程仓库