475 lines
16 KiB
Rust
475 lines
16 KiB
Rust
use crate::entity::{
|
||
appointment, consultation_session, device_readings, doctor_profile, follow_up_task,
|
||
lab_report, patient, patient_devices,
|
||
};
|
||
use crate::fhir::types::{device_type_to_category, device_type_to_loinc, device_type_to_unit};
|
||
|
||
/// HMS Patient → FHIR Patient (JSON Value)
|
||
pub fn patient_to_fhir(p: &patient::Model) -> serde_json::Value {
|
||
let mut result = serde_json::json!({
|
||
"resourceType": "Patient",
|
||
"id": p.id.to_string(),
|
||
"name": [{
|
||
"text": p.name,
|
||
}],
|
||
"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());
|
||
}
|
||
|
||
if let Some(ref id_number) = p.id_number {
|
||
// 加密密文(v1| 前缀)不输出,明文做脱敏处理
|
||
let display_value = if id_number.starts_with("v1|") {
|
||
"[REDACTED]".to_string()
|
||
} else {
|
||
mask_sensitive(id_number)
|
||
};
|
||
result["identifier"] = serde_json::json!([{
|
||
"system": "urn:oid:2.16.156.10011.1.3",
|
||
"value": display_value,
|
||
}]);
|
||
}
|
||
|
||
result
|
||
}
|
||
|
||
/// 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);
|
||
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.as_f64(),
|
||
}
|
||
}
|
||
|
||
/// 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(),
|
||
"identifier": [{
|
||
"system": "urn:hms:device-id",
|
||
"value": d.device_id,
|
||
}],
|
||
"status": "active",
|
||
"meta": { "source": "hms-device" },
|
||
});
|
||
|
||
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" },
|
||
})
|
||
}
|
||
|
||
/// HMS LabReport → FHIR DiagnosticReport
|
||
pub fn lab_report_to_fhir(r: &lab_report::Model) -> serde_json::Value {
|
||
let fhir_status = match r.status.as_str() {
|
||
"reviewed" => "final",
|
||
"pending" => "preliminary",
|
||
_ => "registered",
|
||
};
|
||
|
||
serde_json::json!({
|
||
"resourceType": "DiagnosticReport",
|
||
"id": r.id.to_string(),
|
||
"status": fhir_status,
|
||
"code": {
|
||
"coding": [{
|
||
"system": "urn:hms:report-type",
|
||
"code": r.report_type,
|
||
}]
|
||
},
|
||
"subject": {"reference": format!("Patient/{}", r.patient_id)},
|
||
"effectiveDateTime": r.report_date.to_string(),
|
||
"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",
|
||
};
|
||
|
||
let class_code = match c.consultation_type.as_str() {
|
||
"online" => "VR",
|
||
"offline" => "AMB",
|
||
_ => "AMB",
|
||
};
|
||
|
||
serde_json::json!({
|
||
"resourceType": "Encounter",
|
||
"id": c.id.to_string(),
|
||
"status": fhir_status,
|
||
"class": {
|
||
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
|
||
"code": class_code,
|
||
},
|
||
"subject": {"reference": format!("Patient/{}", c.patient_id)},
|
||
"meta": { "source": "hms-encounter" },
|
||
})
|
||
}
|
||
|
||
/// HMS FollowUpTask → FHIR Task
|
||
pub fn follow_up_to_fhir(t: &follow_up_task::Model) -> serde_json::Value {
|
||
let fhir_status = match t.status.as_str() {
|
||
"pending" => "requested",
|
||
"in_progress" => "in-progress",
|
||
"completed" => "completed",
|
||
"cancelled" => "cancelled",
|
||
_ => "requested",
|
||
};
|
||
|
||
let display = t.content_template.as_deref()
|
||
.unwrap_or(&t.follow_up_type);
|
||
|
||
serde_json::json!({
|
||
"resourceType": "Task",
|
||
"id": t.id.to_string(),
|
||
"status": fhir_status,
|
||
"intent": "plan",
|
||
"focus": {
|
||
"display": display,
|
||
},
|
||
"for": {"reference": format!("Patient/{}", t.patient_id)},
|
||
"meta": { "source": "hms-task" },
|
||
})
|
||
}
|
||
|
||
/// 对敏感字符串做脱敏:保留前 1 位和后 4 位,中间用 * 替代
|
||
fn mask_sensitive(s: &str) -> String {
|
||
if s.len() <= 5 {
|
||
"*".repeat(s.len())
|
||
} else {
|
||
format!("{}{}{}", &s[..1], "*".repeat(s.len() - 5), &s[s.len() - 4..])
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_patient_to_fhir_basic() {
|
||
let p = patient::Model {
|
||
id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
tenant_id: uuid::Uuid::now_v7(),
|
||
user_id: None,
|
||
name: "\u{5f20}\u{4e09}".into(),
|
||
gender: Some("male".into()),
|
||
birth_date: chrono::NaiveDate::from_ymd_opt(1968, 5, 15),
|
||
blood_type: None,
|
||
id_number: Some("110101196805150001".into()),
|
||
id_number_hash: None,
|
||
allergy_history: None,
|
||
medical_history_summary: None,
|
||
emergency_contact_name: None,
|
||
emergency_contact_phone: None,
|
||
emergency_contact_phone_hash: None,
|
||
key_version: None,
|
||
status: "active".into(),
|
||
verification_status: "verified".into(),
|
||
source: None,
|
||
notes: None,
|
||
created_at: chrono::Utc::now(),
|
||
updated_at: chrono::Utc::now(),
|
||
created_by: None,
|
||
updated_by: None,
|
||
deleted_at: None,
|
||
version: 1,
|
||
};
|
||
let fhir = patient_to_fhir(&p);
|
||
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");
|
||
assert_eq!(fhir["identifier"][0]["value"], "1*************0001");
|
||
}
|
||
|
||
#[test]
|
||
fn test_patient_to_fhir_encrypted_id_number_redacted() {
|
||
let p = patient::Model {
|
||
id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||
tenant_id: uuid::Uuid::now_v7(),
|
||
user_id: None,
|
||
name: "测试".into(),
|
||
gender: None,
|
||
birth_date: None,
|
||
blood_type: None,
|
||
id_number: Some("v1|encrypted_payload_here".into()),
|
||
id_number_hash: None,
|
||
allergy_history: None,
|
||
medical_history_summary: None,
|
||
emergency_contact_name: None,
|
||
emergency_contact_phone: None,
|
||
emergency_contact_phone_hash: None,
|
||
key_version: None,
|
||
status: "active".into(),
|
||
verification_status: "pending".into(),
|
||
source: None,
|
||
notes: None,
|
||
created_at: chrono::Utc::now(),
|
||
updated_at: chrono::Utc::now(),
|
||
created_by: None,
|
||
updated_by: None,
|
||
deleted_at: None,
|
||
version: 1,
|
||
};
|
||
let fhir = patient_to_fhir(&p);
|
||
assert_eq!(fhir["identifier"][0]["value"], "[REDACTED]");
|
||
}
|
||
|
||
#[test]
|
||
fn test_mask_sensitive() {
|
||
assert_eq!(mask_sensitive("110101196805150001"), "1*************0001");
|
||
assert_eq!(mask_sensitive("12345"), "*****");
|
||
assert_eq!(mask_sensitive("123456"), "1*3456");
|
||
}
|
||
|
||
#[test]
|
||
fn test_heart_rate_to_fhir_observation() {
|
||
let reading = device_readings::Model {
|
||
id: uuid::Uuid::now_v7(),
|
||
tenant_id: uuid::Uuid::now_v7(),
|
||
patient_id: uuid::Uuid::now_v7(),
|
||
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!((obs["valueQuantity"]["value"].as_f64().unwrap() - 78.0).abs() < f64::EPSILON);
|
||
}
|
||
|
||
#[test]
|
||
fn test_blood_pressure_to_fhir_observations() {
|
||
let reading = device_readings::Model {
|
||
id: uuid::Uuid::now_v7(),
|
||
tenant_id: uuid::Uuid::now_v7(),
|
||
patient_id: uuid::Uuid::now_v7(),
|
||
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}),
|
||
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);
|
||
}
|
||
}
|