الأدلة التقنية

تبادل البيانات الصحية بين الأنظمة: HL7 وFHIR

كيف تنتقل نتائج المختبر وبيانات المريض بين أنظمة المستشفى برسائل HL7 وواجهات FHIR، وكيف تبني ذلك وتختبره بنفسك.

تبادل البيانات الصحية بين الأنظمة: HL7 وFHIR

في مقال أنظمة الصور الطبية رأينا أن الصورة تتحرك في المستشفى بمعيار DICOM، ولكن الصورة جزء صغير من سجل المريض. فالمريضة نفسها لها ملف في نظام التسجيل والدخول، ونتائج في نظام المختبر، وأدوية في نظام الصيدلية، ومطالبة عند شركة التأمين، وكل نظام من شركة مختلفة وقاعدة بيانات مختلفة ورقم مختلف للمريضة نفسها. والطريقة البسيطة التي تبدأ بها أغلب المشاريع هي أن يكتب كل نظام في قاعدة بيانات الآخر مباشرة، أو أن يصدر ملف CSV كل ليلة، والسبب في فشلها أن أي تغيير في جدول واحد يكسر كل من يقرأ منه، وأن نتيجة تحليل قد تصل إلى الطبيب بعد يوم كامل، وأن لا أحد يعرف هل الرقم 5.9 بوحدة mmol/L أو mg/dL. لذلك اتفق القطاع الصحي على معايير للرسائل والواجهات، أقدمها وأوسعها انتشاراً HL7 v2، وأحدثها FHIR.

وهذا هو المقال الثاني في مرحلة التقنية في الصحة، وسوف نناقش فيه ما يلي:

  • السجل الطبي الإلكتروني EHR والأنظمة حوله، ومن يرسل ماذا لمن.
  • رسائل HL7 v2: الشرائح Segments والحقول، مع رسالة دخول ADT ورسالة نتائج ORU نقرؤها حقلاً حقلاً.
  • FHIR: الموارد Resources مثل Patient وObservation وEncounter، وواجهة REST، والبحث، والتحقق Validation، وملفات التعريف Profiles.
  • SMART on FHIR: كيف يحصل تطبيق على بيانات مريض واحد فقط فوق OAuth.
  • أنظمة الترميز: LOINC وSNOMED CT وICD ورخصة كل منها.
  • السياق السعودي من المصادر الرسمية فقط: منصة نفيس NPHIES ودليل التنفيذ الخاص بها.
  • مختبر على Docker مع سيرفر HAPI FHIR: ننشئ مريضة وزيارة ونتائج، ونبحث، ونرسل مورداً خاطئاً ونرى من يكتشفه ومن لا يكتشفه، ونحول رسالة HL7 v2 حقيقية الشكل إلى FHIR بسكربت.

السجل الطبي الإلكتروني والأنظمة حوله

ستسمع ثلاثة مصطلحات متقاربة: السجل الطبي الإلكتروني Electronic Medical Record واختصاراً EMR وهو سجل المريض داخل منشأة واحدة، والسجل الصحي الإلكتروني Electronic Health Record واختصاراً EHR وهو السجل الذي يفترض أن يتبع المريض بين المنشآت ويشاركه، ونظام معلومات المستشفى Hospital Information System واختصاراً HIS وهو النظام الأكبر الذي يشمل السجل والتسجيل والفوترة والمواعيد. وفي الواقع يستخدم الناس الكلمات الثلاث بالتبادل، والمهم لك كمطور هو السؤال: أين النسخة الأصلية من كل معلومة، ومن يرسلها لمن؟ والصورة التالية تبين الأنظمة المعتادة حول السجل:

مخطط: في الوسط السجل الطبي EHR أو HIS، وحوله نظام المختبر LIS يرسل النتائج برسائل HL7 v2 ORU ويستلم رسائل الدخول ADT، ونظام الأشعة RIS وPACS يرسل عبر محرك الواجهات Interface Engine برسائل HL7 v2 أو DICOM SR، والصيدلية تستلم طلبات الدواء ب FHIR MedicationRequest، وشركة التأمين ومنصة NPHIES تستلم رسائل FHIR من نوع Bundle للأهلية والمطالبات، وتطبيق المريض يقرأ ب FHIR وSMART
نفس المريض في أنظمة كثيرة، ولكل نظام نسخته ومعرفاته

لاحظ الصندوق الذي اسمه محرك الواجهات Interface Engine، وهو برنامج يستلم الرسائل من كل نظام ويوجهها ويحول حقولها لتناسب المستقبل، والسبب في وجوده أن ربط عشرة أنظمة ببعضها مباشرة يعني عشرات الاتصالات، وربطها بمحرك واحد يعني عشرة فقط، وهي فكرة الطابور والوسيط نفسها التي شرحناها في مقال الطابور Queue للمبتدئين.

رسائل HL7 v2: نص بسيط يحمل أغلب المستشفيات

معيار HL7 Version 2 من منظمة HL7 International هو أكثر معايير الرسائل الصحية انتشاراً حتى اليوم، والرسالة فيه نص عادي: كل سطر شريحة Segment تبدأ باسم من ثلاثة حروف، والحقول تفصلها |، والمكونات داخل الحقل تفصلها ^، والأسطر تنتهي بحرف \r وليس سطراً جديداً عادياً. وأشهر نوعين من الرسائل هما ADT (الدخول والخروج والنقل Admission, Discharge, Transfer) وORU (نتائج المختبر والفحوص Observation Result). وهذه رسالة دخول مريضة كتبناها للمثال ببيانات وهمية على الإصدار 2.5.1:

MSH|^~\&|HIS|HOSP|LIS|LAB1|20261005084500+0300||ADT^A01^ADT_A01|MSG00041|P|2.5.1
EVN|A01|20261005084500+0300
PID|1||MRN-000123^^^HOSP^MR||Test^Sara||19870314|F
PV1|1|O|OPD^101^1||||12345^Physician^Test

في الرسالة أعلاه لاحظ التالي:

  • الشريحة MSH رأس الرسالة: من أرسلها (HIS في HOSP) ولمن (LIS في LAB1) ومتى، ونوعها ADT^A01 أي دخول مريض، ورقمها MSG00041 الذي يرجع في رسالة الإقرار، وP أي إنتاج Production، والإصدار 2.5.1. والحقل الأول فيها هو الفاصل | نفسه، والثاني ^~\& هو بقية الفواصل، لذلك ترقيم حقول MSH يختلف عن بقية الشرائح بواحد.
  • PID بيانات المريض: PID-3 المعرف MRN-000123 ونوعه MR أي رقم الملف الطبي، وPID-5 الاسم بالشكل العائلة ثم الاسم الأول، وPID-7 تاريخ الميلاد، وPID-8 الجنس.
  • PV1 الزيارة: O أي مراجع عيادات Outpatient، ثم المكان، ثم الطبيب.
  • المستقبل يرد برسالة إقرار ACK فيها الشريحة MSA وأول حقل فيها AA للقبول أو AE للخطأ أو AR للرفض، ومعها رقم الرسالة الأصلية، والرسائل تنقل عادة على TCP ببروتوكول بسيط يسمى MLLP يضع علامة في بداية الرسالة ونهايتها.

أما رسالة النتائج فهي التي سوف نحولها إلى FHIR في المختبر، والصورة التالية تفككها:

رسالة ORU^R01 من خمسة أسطر: MSH رأس الرسالة، وPID المريضة MRN-000123، وOBR طلب لوحة التحاليل 24323-8، وسطران OBX للكرياتينين 0.8 mg/dL والبوتاسيوم 5.9 mmol/L. وتحتها تفكيك سطر OBX الثاني: OBX-1 رقم 1، وOBX-2 النوع NM رقمي، وOBX-3 رمز LOINC 2823-3، وOBX-5 القيمة 5.9، وOBX-6 الوحدة، وOBX-7 المدى المرجعي 3.5-5.1، وOBX-8 العلامة H، وOBX-11 الحالة F نهائي، وOBX-14 الوقت، مع ملاحظة أن الحقول الفارغة تحسب في الترقيم
كل قيمة لها رقم حقل ثابت، والحقول الفارغة تحسب في الترقيم

وقد يتساءل البعض: إذا كان HL7 v2 بهذه البساطة فلماذا جاء FHIR؟ والإجابة أن البساطة نفسها هي المشكلة: كل مستشفى يملأ الحقول الاختيارية بطريقته، ويضيف شرائح خاصة تبدأ بالحرف Z، فلا توجد رسالتا ADT من نظامين مختلفين بنفس الشكل تماماً، ولذلك يقال في القطاع إنك إذا رأيت واجهة HL7 v2 واحدة فقد رأيت واجهة واحدة فقط. ثم إن الرسالة تدفع حدثاً من نظام لآخر ولا تسمح لتطبيق أن يسأل «أعطني آخر نتيجة سكر لهذا المريض»، وهذا بالضبط ما تقدمه FHIR.

FHIR: موارد صغيرة وواجهة REST

FHIR (اختصار Fast Healthcare Interoperability Resources) هو معيار HL7 الحديث، ويقسم البيانات الصحية إلى موارد Resources صغيرة لكل منها شكل محدد، مثل Patient للمريض وObservation لأي قياس أو نتيجة وEncounter للزيارة، وتجد القائمة كاملة في فهرس الموارد. والموارد يشير بعضها إلى بعض بمرجع Reference بالشكل Patient/1000، وتتعامل معها بواجهة REST عادية كما شرحنا في مقال تصميم واجهات API: POST للإنشاء وGET للقراءة والبحث وPUT للتعديل، وهذا موضح في صفحة واجهة REST في المعيار.

والإصدار المنشور الحالي من المعيار هو R5 (5.0.0)، ولكن أغلب ما يعمل في الإنتاج اليوم مبني على R4 (4.0.1)، وهو أول إصدار أصبحت فيه أجزاء من المعيار معيارية Normative لا تتغير إلا مع التوافق الخلفي، ومنها المورد Patient، أما Encounter فما زال في R4 بمستوى نضج 2 أي تحت التجربة Trial Use. وسوف نعمل على R4 في المختبر لأن الدليل السعودي الذي نذكره لاحقاً مبني عليه أيضاً.

على اليسار المورد Patient/1000 برقم الملف MRN-000123، والمورد Encounter/1001 زيارة عيادة يشير إليه بالحقل subject، وثلاث Observation بالأرقام 1002 للسكر 98 و1003 لنبض القلب 72 و1008 للبوتاسيوم 5.9 تشير كلها إلى المريضة، واثنتان منها إلى الزيارة بالحقل encounter. وعلى اليمين أفعال REST: POST /Patient للإنشاء ويرجع 201، وGET /Patient/1000 للقراءة، وPUT للتعديل بنسخة جديدة، وGET /Observation?code للبحث ويرجع Bundle، وPOST / بحزمة Bundle للمعاملة، وPOST /Observation/$validate للتحقق دون حفظ، وGET /Patient/1000/$everything للسجل كاملاً
الموارد التي سوف ننشئها في المختبر بأرقامها الحقيقية، والأفعال التي سوف نستخدمها

تجهيز المختبر: HAPI FHIR

HAPI FHIR مكتبة Java مفتوحة المصدر برخصة Apache-2.0، ومعها مشروع HAPI FHIR JPA Server Starter وهو سيرفر FHIR كامل يحفظ الموارد في قاعدة بيانات، والمشروعان برخصة Apache-2.0 كما في مستودعيهما. ونشغله بإصدار محدد على منفذ محلي:

docker network create lab-health-net
docker run -d --name lab-health-hapi --network lab-health-net -p 127.0.0.1:18080:8080 hapiproject/hapi:v8.12.0-2
# wait until /fhir/metadata answers (about a minute and a half)
until curl -sf -o /dev/null 127.0.0.1:18080/fhir/metadata; do sleep 5; done
curl -s 127.0.0.1:18080/fhir/metadata | jq -c '{fhirVersion, software, impl: .implementation.description, resources: (.rest[0].resource|length)}'
docker logs lab-health-hapi 2>&1 | grep -o "url=jdbc[^ ]*"
{"fhirVersion":"4.0.1","software":{"name":"HAPI FHIR Server","version":"8.12.0"},"impl":"HAPI FHIR R4 Server","resources":146}
url=jdbc:h2:mem:test_mem

في المخرج أعلاه لاحظ التالي:

  • المسار /fhir/metadata يرجع بيان القدرات CapabilityStatement، وهو أول طلب يرسله أي تطبيق FHIR ليعرف الإصدار والموارد وعمليات البحث المدعومة، وهنا 146 نوعاً من الموارد على FHIR 4.0.1.
  • قاعدة البيانات الافتراضية في هذه ال Image هي H2 في الذاكرة jdbc:h2:mem، أي أن كل ما ننشئه يختفي عند إعادة تشغيل ال Container، وهذا مناسب للمختبر فقط.

ننشئ مريضة وزيارة ونتائج

نبدأ بالمريضة، ببيانات وهمية ومعرف من نظام وهمي https://hospital.example.org/mrn:

{
  "resourceType": "Patient",
  "identifier": [{ "system": "https://hospital.example.org/mrn", "value": "MRN-000123" }],
  "name": [{ "family": "Test", "given": ["Sara"] }],
  "gender": "female",
  "birthDate": "1987-03-14"
}
curl -s -D h1.txt -o r1.json -X POST -H "Content-Type: application/fhir+json" --data @patient.json http://127.0.0.1:18080/fhir/Patient
grep -iE "^HTTP|^location|^etag|last-modified" h1.txt; cat r1.json
HTTP/1.1 201
ETag: W/"1"
Last-Modified: Fri, 09 Oct 2026 09:02:50 GMT
Location: http://127.0.0.1:18080/fhir/Patient/1000/_history/1
{
  "resourceType": "Patient",
  "id": "1000",
  "meta": {
    "versionId": "1",
    "lastUpdated": "2026-10-09T09:02:50.745+00:00"
  },
  "identifier": [ {
    "system": "https://hospital.example.org/mrn",
    "value": "MRN-000123"
  } ],
  "name": [ {
    "family": "Test",
    "given": [ "Sara" ]
  } ],
  "gender": "female",
  "birthDate": "1987-03-14"
}

لاحظ أن السيرفر أعطى المريضة المعرف 1000 ورقم نسخة versionId بقيمة 1 ورأس ETag بنفس الرقم، فكل تعديل يصنع نسخة جديدة تبقى في _history، وهذا مهم في السجلات الطبية لأنك تحتاج أن تعرف ما كان مكتوباً وقت اتخاذ القرار. ولاحظ أن المعرف 1000 رقم السيرفر نفسه، أما رقم الملف الطبي فهو في identifier مع النظام الذي أصدره، والفرق بينهما هو الفرق بين المفتاح الداخلي في قاعدة البيانات والرقم الذي يعرفه الناس.

ثم الزيارة، ثم نتيجة سكر مرتبطة بالمريضة والزيارة ومرمزة برمز LOINC ووحدة UCUM:

{
  "resourceType": "Encounter",
  "status": "finished",
  "class": { "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", "code": "AMB", "display": "ambulatory" },
  "subject": { "reference": "Patient/1000" },
  "period": { "start": "2026-10-05T09:00:00+03:00", "end": "2026-10-05T09:40:00+03:00" }
}
{
  "resourceType": "Observation",
  "status": "final",
  "category": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "laboratory" }] }],
  "code": { "coding": [{ "system": "http://loinc.org", "code": "2339-0", "display": "Glucose [Mass/volume] in Blood" }] },
  "subject": { "reference": "Patient/1000" },
  "encounter": { "reference": "Encounter/1001" },
  "effectiveDateTime": "2026-10-05T09:10:00+03:00",
  "valueQuantity": { "value": 98, "unit": "mg/dL", "system": "http://unitsofmeasure.org", "code": "mg/dL" }
}
curl -s -X POST -H "Content-Type: application/fhir+json" --data @encounter.json http://127.0.0.1:18080/fhir/Encounter | jq -c '{resourceType,id,status}'
curl -s -X POST -H "Content-Type: application/fhir+json" --data @obs-glucose.json http://127.0.0.1:18080/fhir/Observation | jq -c '{resourceType,id,status,code:.code.coding[0].code,value:.valueQuantity.value}'
{"resourceType":"Encounter","id":"1001","status":"finished"}
{"resourceType":"Observation","id":"1002","status":"final","code":"2339-0","value":98}

والنتائج الثلاث التالية نرسلها في طلب واحد كحزمة من نوع معاملة Transaction Bundle، فإما أن تحفظ كلها أو لا يحفظ شيء، كما في تعريف Bundle:

{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    { "request": { "method": "POST", "url": "Observation" },
      "resource": { "resourceType": "Observation", "status": "final",
        "code": { "coding": [{ "system": "http://loinc.org", "code": "8867-4", "display": "Heart rate" }] },
        "subject": { "reference": "Patient/1000" }, "encounter": { "reference": "Encounter/1001" },
        "effectiveDateTime": "2026-10-05T09:05:00+03:00",
        "valueQuantity": { "value": 72, "unit": "beats/minute", "system": "http://unitsofmeasure.org", "code": "/min" } } },
    { "request": { "method": "POST", "url": "Observation" },
      "resource": { "resourceType": "Observation", "status": "final",
        "code": { "coding": [{ "system": "http://loinc.org", "code": "4548-4", "display": "Hemoglobin A1c/Hemoglobin.total in Blood" }] },
        "subject": { "reference": "Patient/1000" }, "encounter": { "reference": "Encounter/1001" },
        "effectiveDateTime": "2026-10-05T09:10:00+03:00",
        "valueQuantity": { "value": 5.4, "unit": "%", "system": "http://unitsofmeasure.org", "code": "%" } } },
    { "request": { "method": "POST", "url": "Observation" },
      "resource": { "resourceType": "Observation", "status": "final",
        "code": { "coding": [{ "system": "http://loinc.org", "code": "2339-0", "display": "Glucose [Mass/volume] in Blood" }] },
        "subject": { "reference": "Patient/1000" },
        "effectiveDateTime": "2026-10-08T08:30:00+03:00",
        "valueQuantity": { "value": 104, "unit": "mg/dL", "system": "http://unitsofmeasure.org", "code": "mg/dL" } } }
  ]
}
curl -s -X POST -H "Content-Type: application/fhir+json" --data @bundle.json http://127.0.0.1:18080/fhir | jq -c '{type, results: [.entry[].response | {status, location}]}'
{"type":"transaction-response","results":[{"status":"201 Created","location":"Observation/1003/_history/1"},{"status":"201 Created","location":"Observation/1004/_history/1"},{"status":"201 Created","location":"Observation/1005/_history/1"}]}

البحث: الاستعلامات التي يحتاجها أي تطبيق

البحث في FHIR يتم بمعاملات في الرابط معرفة لكل مورد، وقواعدها في صفحة البحث في المعيار، والمخرج دائماً Bundle من نوع searchset. وفيما يلي ستة استعلامات على ما أنشأناه:

F=http://127.0.0.1:18080/fhir
# 1. find the patient by her MRN (system|value)
curl -s "$F/Patient?identifier=https://hospital.example.org/mrn|MRN-000123" | jq -c '{total, ids: [.entry[].resource.id]}'
# 2. her glucose results, newest first
curl -s -G "$F/Observation" --data-urlencode "subject=Patient/1000" --data-urlencode "code=http://loinc.org|2339-0" --data-urlencode "_sort=-date" \
  | jq -c '.total, (.entry[].resource | {id, date: .effectiveDateTime, value: .valueQuantity.value, unit: .valueQuantity.unit})'
# 3. anything measured from 6 October on
curl -s -G "$F/Observation" --data-urlencode "patient=1000" --data-urlencode "date=ge2026-10-06" | jq -c '{total, ids:[.entry[].resource.id]}'
# 4. glucose values above 100
curl -s -G "$F/Observation" --data-urlencode "code=http://loinc.org|2339-0" --data-urlencode "value-quantity=gt100" | jq -c '{total, ids:[.entry[].resource.id]}'
# 5. the visit, its observations and its patient in one request
curl -s -G "$F/Encounter" --data-urlencode "_id=1001" --data-urlencode "_revinclude=Observation:encounter" --data-urlencode "_include=Encounter:subject" \
  | jq -c '{total, entries: [.entry[] | {r: .resource.resourceType, id: .resource.id, mode: .search.mode}]}'
# 6. everything about the patient
curl -s "$F/Patient/1000/\$everything" | jq -c '{total, types: [.entry[].resource.resourceType]}'
{"total":1,"ids":["1000"]}
2
{"id":"1005","date":"2026-10-08T08:30:00+03:00","value":104,"unit":"mg/dL"}
{"id":"1002","date":"2026-10-05T09:10:00+03:00","value":98,"unit":"mg/dL"}
{"total":1,"ids":["1005"]}
{"total":1,"ids":["1005"]}
{"total":1,"entries":[{"r":"Encounter","id":"1001","mode":"match"},{"r":"Observation","id":"1002","mode":"include"},{"r":"Observation","id":"1003","mode":"include"},{"r":"Observation","id":"1004","mode":"include"},{"r":"Patient","id":"1000","mode":"include"}]}
{"total":6,"types":["Patient","Encounter","Observation","Observation","Observation","Observation"]}

في المخرج أعلاه لاحظ التالي:

  • البحث بالمعرف يكتب بالشكل system|value، وهذا يمنع الخلط بين رقم ملف MRN-000123 في مستشفى ورقم مطابق له في مستشفى آخر.
  • البحث بالرمز مثله: http://loinc.org|2339-0، فالرمز وحده دون النظام قد يطابق رمزاً محلياً بنفس الأرقام.
  • المعاملات ge وgt في التاريخ والقيمة بادئات للمقارنة، وهي جزء من المعيار وليست من HAPI.
  • الاستعلام الخامس جلب الزيارة ومعها الملاحظات التي تشير إليها _revinclude والمريضة التي تشير هي إليها _include في طلب واحد، والحقل mode يخبرك أي المدخلات طابقت البحث وأيها جاءت كملحق. ولاحظ أن النتيجة 1005 ليست معها لأننا لم نربطها بالزيارة.
  • $everything عملية Operation وليست بحثاً، ولذلك تبدأ ب $، وهي معرفة في صفحة العملية في المعيار وترجع كل ما يخص المريض، وهي مفيدة لنقل ملف كامل ولكنها ثقيلة على سيرفر كبير، فلا تستخدمها في شاشة تفتح كثيراً.

والسيرفر يأتي بواجهة ويب للتجربة على المسار الرئيسي، والصورة التالية فيها قراءة نتيجة البوتاسيوم التي سوف ننشئها من رسالة HL7 v2 في قسم لاحق:

واجهة الاختبار في HAPI FHIR: طلب GET إلى Observation/1008 ورد HTTP 200 مع الرؤوس ومنها ETag ونسخة FHIR 4.0.1، وفي الأسفل جسم المورد بصيغة JSON فيه الرمز 2823-3 من LOINC والقيمة 5.9 mmol/L والمرجع Patient/1000 والعلامة H والمدى المرجعي من 3.5 إلى 5.1، وعلى اليسار قائمة أنواع الموارد
واجهة الاختبار في HAPI FHIR تعرض الطلب والرد كما هما

نكسر المورد: من يكتشف الخطأ ومن لا يكتشفه؟

هذا أهم قسم في المختبر، لأن الخطأ في البيانات الصحية لا يظهر في الشاشة وإنما يظهر بعد أشهر عندما لا يجد تقرير أو تطبيق ما يبحث عنه. نبدأ بمورد فيه ثلاثة أخطاء: حالة غير موجودة done، وتاريخ بشكل خاطئ، ولا يوجد رمز code أصلاً:

{
  "resourceType": "Observation",
  "status": "done",
  "subject": { "reference": "Patient/1000" },
  "effectiveDateTime": "2026-10-05 09:10",
  "valueQuantity": { "value": 98, "unit": "mg/dL", "system": "http://unitsofmeasure.org", "code": "mg/dL" }
}
curl -s -X POST -H "Content-Type: application/fhir+json" --data @obs-bad.json "$F/Observation" -o out.json -w 'HTTP %{http_code}\n'
jq -c '.issue[] | {severity, msg: .diagnostics}' < out.json
HTTP 400
{"severity":"error","msg":"HAPI-0450: Failed to parse request body as JSON resource. Error was: HAPI-1821: [element=\"status\"] Invalid attribute value \"done\": Unknown ObservationStatus code 'done'"}

رفض السيرفر الطلب عند القراءة Parsing قبل أي تحقق، والسبب أن status قيمة من قائمة مغلقة في المعيار. فنصلح الحالة إلى final في الملف obs-bad2.json ونعيد نفس الأمرين:

HTTP 400
{"severity":"error","msg":"HAPI-0450: Failed to parse request body as JSON resource. Error was: HAPI-1821: [element=\"effectiveDateTime\"] Invalid attribute value \"2026-10-05 09:10\": Invalid date/time format: \"2026-10-05 09:10\""}

والتاريخ كذلك يجب أن يكون بالشكل 2026-10-05T09:10:00+03:00 مع المنطقة الزمنية. والآن نصلح التاريخ ونترك الخطأ الثالث (لا يوجد رمز)، ونرسل المورد إلى العملية $validate التي تفحص دون أن تحفظ، ثم نرسله للحفظ:

echo "== validate"; curl -s -X POST -H "Content-Type: application/fhir+json" --data @obs-bad3.json "$F/Observation/\$validate" -w '\nHTTP %{http_code}\n' > v.out
tail -1 v.out; head -n -1 v.out | jq -c '.issue[] | {severity, where: (.expression // .location)[0], msg: .diagnostics}'
echo "== create"; curl -s -X POST -H "Content-Type: application/fhir+json" --data @obs-bad3.json "$F/Observation" -w '\nHTTP %{http_code}\n' > c.out
tail -1 c.out; head -n -1 c.out | jq -c 'if .resourceType=="OperationOutcome" then .issue[] | {severity, msg: .diagnostics} else {resourceType,id,code} end'
== validate
HTTP 200
{"severity":"error","where":"Observation","msg":"Observation.code: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Observation|4.0.1)"}
{"severity":"warning","where":"Observation","msg":"Constraint failed: dom-6: 'A resource should have narrative for robust management' (defined in http://hl7.org/fhir/StructureDefinition/DomainResource) (Best Practice Recommendation)"}
{"severity":"warning","where":"Observation","msg":"Best Practice Recommendation: In general, all observations should have a performer"}
== create
HTTP 201
{"resourceType":"Observation","id":"1006","code":null}

وهذا هو الدرس: $validate وجد الخطأ بوضوح، فالحقل Observation.code إلزامي (1..1) في تعريف المورد، ولكن نفس السيرفر قبل نفس المورد وحفظه برقم 1006 دون رمز، أي نتيجة لا يعرف أحد ماذا قاست. والسبب أن HAPI JPA لا يطبق التحقق الكامل عند الحفظ افتراضياً لأنه مكلف، فيكتفي بقراءة الصيغة. والحل أن تفعل التحقق عند الكتابة بالإعداد hapi.fhir.validation.requests_enabled الموجود في ملف الإعداد في مستودع المشروع، فنشغل سيرفراً ثانياً به ونرسل المورد نفسه:

docker run -d --name lab-health-hapi-strict --network lab-health-net -p 127.0.0.1:18081:8080 \
  -e hapi.fhir.validation.requests_enabled=true hapiproject/hapi:v8.12.0-2
curl -s -X POST -H "Content-Type: application/fhir+json" --data @obs-bad3.json "http://127.0.0.1:18081/fhir/Observation" -w '\nHTTP %{http_code}\n' > c2.out
tail -1 c2.out; head -n -1 c2.out | jq -c 'if .resourceType=="OperationOutcome" then .issue[] | {severity, msg: .diagnostics} else {resourceType,id} end'
HTTP 422
{"severity":"error","msg":"Observation.code: minimum required = 1, but only found 0 (from http://hl7.org/fhir/StructureDefinition/Observation|4.0.1)"}
{"severity":"warning","msg":"Constraint failed: dom-6: 'A resource should have narrative for robust management' (defined in http://hl7.org/fhir/StructureDefinition/DomainResource) (Best Practice Recommendation)"}
{"severity":"warning","msg":"Best Practice Recommendation: In general, all observations should have a performer"}

الآن رفض السيرفر المورد برمز 422 مع نفس الرسالة، بينما يقبل الموارد التي ليس فيها إلا تحذيرات، فقد أرسلنا إليه المريضة نفسها فرجع 201. والخطأ الأخير أخطر من كل ما سبق: نرسل نتيجة السكر الصحيحة إلى $validate، ثم نفس النتيجة برمز LOINC غير موجود 2339-9:

sed 's/"2339-0"/"2339-9"/' obs-glucose.json > obs-badcode.json
for f in obs-glucose.json obs-badcode.json; do echo "## validate $f"
  curl -s -X POST -H "Content-Type: application/fhir+json" --data @$f "$F/Observation/\$validate" | jq -c '.issue[] | {severity, msg: .diagnostics}'; done
## validate obs-glucose.json
{"severity":"warning","msg":"CodeSystem is unknown and can't be validated: http://loinc.org for 'http://loinc.org#2339-0'"}
{"severity":"warning","msg":"Constraint failed: dom-6: 'A resource should have narrative for robust management' (defined in http://hl7.org/fhir/StructureDefinition/DomainResource) (Best Practice Recommendation)"}
{"severity":"warning","msg":"Best Practice Recommendation: In general, all observations should have a performer"}
## validate obs-badcode.json
{"severity":"warning","msg":"CodeSystem is unknown and can't be validated: http://loinc.org for 'http://loinc.org#2339-9'"}
{"severity":"warning","msg":"Constraint failed: dom-6: 'A resource should have narrative for robust management' (defined in http://hl7.org/fhir/StructureDefinition/DomainResource) (Best Practice Recommendation)"}
{"severity":"warning","msg":"Best Practice Recommendation: In general, all observations should have a performer"}

نفس التحذير للرمز الصحيح والرمز الخاطئ، والسبب أن السيرفر لا يحمل قاموس LOINC أصلاً فلا يعرف الرموز، أي أن التحقق البنيوي لا يتحقق من المعنى. لذلك ففي الإنتاج اربط السيرفر بخادم مصطلحات Terminology Server محمل بالأنظمة التي تستخدمها، أو حمل LOINC فيه بعد قبول رخصته، وإلا فالتحقق يخبرك أن الشكل صحيح فقط. وتفاصيل طبقات التحقق موجودة في صفحة التحقق في المعيار وتوثيق التحقق في HAPI.

ملفات التعريف Profiles وأدلة التنفيذ

المورد الأساسي في FHIR مرن جداً عن قصد: أغلب الحقول اختيارية، وأغلب الرموز من أي نظام. وهذه المرونة جيدة للمعيار العالمي ومزعجة لأي مشروع حقيقي، لذلك تكتب كل دولة أو جهة ملف تعريف Profile يضيق المورد بحسب قواعد التخصيص في المعيار: هذا الحقل إلزامي عندنا، وهذا الرمز يجب أن يكون من القائمة الفلانية، وهذا الحقل الإضافي Extension نضيفه. وتجمع الملفات في دليل تنفيذ Implementation Guide له رابط وإصدار، مثل ملخص المريض الدولي IPS، أو US Core في أمريكا. والمورد الذي يصل إلى سيرفر يطبق ملف تعريف يعلن ذلك في meta.profile، ويتحقق منه السيرفر بنفس العملية $validate بعد تحميل حزمة الدليل.

والقاعدة العملية: لا تبن تكاملاً على المورد الأساسي وحده، وابحث أولاً عن دليل التنفيذ الذي يلزمك به الطرف الآخر، فهو الذي يحدد ما سوف يرفض وما سوف يقبل.

من HL7 v2 إلى FHIR بسكربت

في الواقع لن تختار بين المعيارين، فالمختبر سوف يستمر في إرسال HL7 v2 لسنوات، وتطبيقك الجديد يريد FHIR. لذلك نشرت HL7 دليل التحويل من V2 إلى FHIR بإصدار 1.0.0، وفيه جداول تطابق كل شريحة وحقل بما يقابلها في الموارد. والسكربت التالي يطبق جزءاً صغيراً منه على رسالة ORU التي رأيتها في الصورة: PID إلى Patient وOBX إلى Observation، ثم يرسلها إلى السيرفر كمعاملة واحدة:

# v2_to_fhir.py
# Converts one HL7 v2.5.1 ORU^R01 message into a FHIR R4 transaction Bundle and sends it to the server.
import json, sys, requests

def parse(msg):
    segs = {}
    for line in msg.strip("\r\n").split("\r"):
        fields = line.split("|")
        if fields[0] == "MSH":                     # MSH-1 is the "|" itself, so shift by one
            fields = ["MSH", "|"] + fields[1:]
        segs.setdefault(fields[0], []).append(fields)
    return segs

comp = lambda f, i: (f.split("^") + [""] * 10)[i]                  # component i of a field (0-based)
def ts(v):                                                            # 20261005091000+0300 -> 2026-10-05T09:10:00+03:00
    d, z = v[:14], v[14:]
    return f"{d[0:4]}-{d[4:6]}-{d[6:8]}T{d[8:10]}:{d[10:12]}:{d[12:14]}" + (f"{z[:3]}:{z[3:]}" if z else "")

STATUS = {"F": "final", "P": "preliminary", "C": "corrected", "X": "cancelled"}
SEX = {"F": "female", "M": "male", "O": "other", "U": "unknown"}

seg = parse(open(sys.argv[1], newline="").read())
msh, pid = seg["MSH"][0], seg["PID"][0]
print("message type:", msh[9], " control id:", msh[10], " version:", msh[12])
mrn = comp(pid[3], 0)
patient = {"resourceType": "Patient",
           "identifier": [{"system": "https://hospital.example.org/mrn", "value": mrn}],
           "name": [{"family": comp(pid[5], 0), "given": [comp(pid[5], 1)]}],
           "birthDate": f"{pid[7][:4]}-{pid[7][4:6]}-{pid[7][6:8]}",
           "gender": SEX.get(pid[8], "unknown")}
entries = [{"fullUrl": "urn:uuid:patient-1", "resource": patient,
            "request": {"method": "POST", "url": "Patient",
                        "ifNoneExist": f"identifier=https://hospital.example.org/mrn|{mrn}"}}]
for i, obx in enumerate(seg["OBX"], 1):
    lo, hi = obx[7].split("-")
    unit = comp(obx[6], 0)
    obs = {"resourceType": "Observation", "status": STATUS[obx[11]],
           "category": [{"coding": [{"system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "laboratory"}]}],
           "code": {"coding": [{"system": "http://loinc.org", "code": comp(obx[3], 0), "display": comp(obx[3], 1)}]},
           "subject": {"reference": "urn:uuid:patient-1"},
           "effectiveDateTime": ts(obx[14]),
           "valueQuantity": {"value": float(obx[5]), "unit": unit, "system": "http://unitsofmeasure.org", "code": unit},
           "interpretation": [{"coding": [{"system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", "code": obx[8]}]}],
           "referenceRange": [{"low": {"value": float(lo), "unit": unit}, "high": {"value": float(hi), "unit": unit}}]}
    entries.append({"fullUrl": f"urn:uuid:obs-{i}", "resource": obs, "request": {"method": "POST", "url": "Observation"}})
bundle = {"resourceType": "Bundle", "type": "transaction", "entry": entries}
print(json.dumps(entries[2]["resource"], indent=1, ensure_ascii=False))
r = requests.post("http://lab-health-hapi:8080/fhir", json=bundle, headers={"Content-Type": "application/fhir+json"})
print("HTTP", r.status_code)
for e in r.json()["entry"]:
    print(" ", e["response"]["status"], e["response"]["location"])

نكتب الرسالة في ملف بفواصل \r كما ترسلها الأنظمة، ونشغل السكربت في Container بايثون على نفس الشبكة:

printf 'MSH|^~\\&|LIS|LAB1|EHR|HOSP|20261005093000+0300||ORU^R01^ORU_R01|MSG00042|P|2.5.1\rPID|1||MRN-000123^^^HOSP^MR||Test^Sara||19870314|F\rOBR|1||LAB-7781|24323-8^Comprehensive metabolic panel - Serum or Plasma^LN|||20261005091000+0300\rOBX|1|NM|2160-0^Creatinine [Mass/volume] in Serum or Plasma^LN||0.8|mg/dL^mg/dL^UCUM|0.5-1.1|N|||F|||20261005091000+0300\rOBX|2|NM|2823-3^Potassium [Moles/volume] in Serum or Plasma^LN||5.9|mmol/L^mmol/L^UCUM|3.5-5.1|H|||F|||20261005091000+0300\r' > oru.hl7
docker run -d --name lab-health-py --network lab-health-net -v "$PWD:/work" -w /work python:3.13.16-slim sleep infinity
docker exec lab-health-py pip install -q requests==2.34.2
docker exec lab-health-py python v2_to_fhir.py oru.hl7
message type: ORU^R01^ORU_R01  control id: MSG00042  version: 2.5.1
{
 "resourceType": "Observation",
 "status": "final",
 "category": [
  {
   "coding": [
    {
     "system": "http://terminology.hl7.org/CodeSystem/observation-category",
     "code": "laboratory"
    }
   ]
  }
 ],
 "code": {
  "coding": [
   {
    "system": "http://loinc.org",
    "code": "2823-3",
    "display": "Potassium [Moles/volume] in Serum or Plasma"
   }
  ]
 },
 "subject": {
  "reference": "urn:uuid:patient-1"
 },
 "effectiveDateTime": "2026-10-05T09:10:00+03:00",
 "valueQuantity": {
  "value": 5.9,
  "unit": "mmol/L",
  "system": "http://unitsofmeasure.org",
  "code": "mmol/L"
 },
 "interpretation": [
  {
   "coding": [
    {
     "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation",
     "code": "H"
    }
   ]
  }
 ],
 "referenceRange": [
  {
   "low": {
    "value": 3.5,
    "unit": "mmol/L"
   },
   "high": {
    "value": 5.1,
    "unit": "mmol/L"
   }
  }
 ]
}
HTTP 200
  200 OK Patient/1000/_history/1
  201 Created Observation/1007/_history/1
  201 Created Observation/1008/_history/1

في المخرج أعلاه لاحظ التالي:

  • المريضة لم تتكرر: أول سطر في الرد 200 OK Patient/1000 وليس 201 Created، والسبب الحقل ifNoneExist الذي يجعل الإنشاء مشروطاً Conditional Create كما في صفحة واجهة REST: ابحث بالمعرف أولاً، فإن وجدته فاستخدمه ولا تنشئ جديداً. ولولا ذلك لأصبح لكل رسالة نتائج مريض جديد بنفس الاسم، وهي من أكثر المشاكل شيوعاً في التكامل الصحي.
  • الملاحظات تشير إلى urn:uuid:patient-1 داخل الحزمة، والسيرفر استبدلها بالمرجع الحقيقي Patient/1000 عند الحفظ، وتأكدنا من ذلك بقراءة المورد 1008 فوجدنا "subject":"Patient/1000".
  • العلامة H والمدى المرجعي نقلناهما كما أرسلهما المختبر في الرسالة، فالسكربت لا يحكم على القيمة، وهذا هو الصحيح: المحول ينقل المعنى ولا يضيف تفسيراً من عنده.
  • هذا السكربت للتعلم فقط، فالمحول الحقيقي يتعامل مع فواصل مختلفة يعلنها MSH-2 ومع رموز الهروب Escape Sequences ومع تكرار الحقول بالفاصل ~، ومع رسائل التصحيح والإلغاء، والأفضل أن تستخدم محرك واجهات أو مكتبة جاهزة تطبق دليل التحويل الرسمي.

SMART on FHIR: الدخول لبيانات مريض واحد

سيرفر FHIR في المختبر مفتوح لأي أحد، وفي الإنتاج يجب أن يعرف السيرفر من يطلب وماذا يسمح له أن يرى. والمعيار المستخدم لذلك هو SMART App Launch بإصداره الحالي 2.2.0، وهو ملف تعريف Profile فوق OAuth 2.0 وOpenID Connect اللذين شرحناهما بالتفصيل في مقال شرح OAuth وOpenID Connect، فإذا لم تقرأه فابدأ به. والإضافة التي يقدمها SMART هي سياق المريض: التطبيق لا يحصل على توكن يفتح كل السجلات، وإنما على توكن ومعه رقم مريض واحد ونطاقات Scopes مكتوبة بلغة الموارد:

مخطط: تطبيق SMART على الجوال يطلب أولاً ملف الاكتشاف .well-known/smart-configuration من سيرفر FHIR، ثم يرسل طلب التفويض إلى سيرفر التفويض مع النطاقات وPKCE بطريقة S256 والمعامل aud، ثم يسجل المستخدم الدخول ويختار المريض ويوافق، ثم يحصل التطبيق على الكود ويستبدله بتوكن ومعه patient=1000، ثم يطلب Observation?patient=1000 بالتوكن، ومثال النطاق launch/patient openid fhirUser patient/Observation.rs
التوكن في SMART مقيد بمريض واحد وبأنواع موارد وأفعال محددة
  • الاكتشاف: كل سيرفر FHIR يطلب تفويضاً يجب أن ينشر ملف JSON على المسار /.well-known/smart-configuration بحسب صفحة القدرات في الدليل، وفيه عناوين التفويض والتوكن والقدرات المدعومة.
  • PKCE إلزامي: الدليل ينص في صفحة إطلاق التطبيق على أن السيرفر يجب أن يدعم الطريقة S256 ويجب ألا يدعم الطريقة plain، وعلى التطبيق أن يرسل المعامل aud بعنوان سيرفر FHIR حتى لا يستعمل التوكن مع سيرفر آخر.
  • النطاقات: بحسب صفحة النطاقات فالنطاق patient/Observation.rs يعني قراءة Read وبحث Search في الملاحظات لهذا المريض فقط، ويمكن تضييقه أكثر بمعامل بحث مثل patient/Observation.rs?category=http://terminology.hl7.org/CodeSystem/observation-category|laboratory لنتائج المختبر وحدها، أما launch/patient فيطلب من السيرفر أن يجعل المستخدم يختار مريضاً عند الإطلاق.
  • نوعان من الإطلاق: الإطلاق من داخل السجل EHR Launch حيث يفتح الطبيب التطبيق من شاشة مريض فيأتي السياق معه، والإطلاق المستقل Standalone Launch حيث يفتح المريض التطبيق من جواله ويختار نفسه.

والخطأ الشائع هنا أن يطلب التطبيق user/*.read أو ما يشبهه لأن ذلك أسهل، فيحصل على كل ما يراه الطبيب في السجل، ثم يحفظه في قاعدة بياناته. لا تقم بذلك، والسبب أن كل بيان صحي تنقله خارج السجل يصبح مسؤوليتك بالكامل بحسب نظام حماية البيانات الشخصية، فاطلب أضيق نطاق يكفي وظيفة التطبيق.

أنظمة الترميز: LOINC وSNOMED CT وICD

رأيت أن كل قيمة مرمزة في FHIR تحمل معها رابط نظامها، والسبب أن الرمز وحده رقم لا معنى له. والصورة التالية تجمع الأنظمة التي سوف تقابلها، وماذا يرمز كل منها، وحالة رخصته:

أربعة أعمدة: LOINC لما تم قياسه ومثاله 2823-3 البوتاسيوم ورخصته مجانية برخصة خاصة من Regenstrief، وSNOMED CT للمعنى السريري من نتائج وتشخيصات وإجراءات ومجاني في الدول الأعضاء والسعودية منها، وICD-10 وICD-10-AM للإحصاء والفوترة من منظمة الصحة العالمية ومنصة NPHIES تستخدم ICD-10-AM، وUCUM للوحدات ومجاني. وفي الأسفل مثال JSON لحقل code برابط النظام http://loinc.org
كل نظام يجيب عن سؤال مختلف، ولكل منه مالك ورخصة
  • LOINC: يرمز ما تم قياسه أو سؤاله، مثل 2339-0 الذي يصفه موقعه بأنه Glucose [Mass/volume] in Blood، وتملكه مؤسسة Regenstrief، ورخصته تنص على أنه متاح مجاناً للاستخدام التجاري وغير التجاري بشروط مكتوبة في الصفحة نفسها، فاقرأها قبل أن تضع قاموسه في منتجك.
  • SNOMED CT: أوسع نظام للمعنى السريري (التشخيصات والنتائج والإجراءات ومواضع الجسم)، وتملكه SNOMED International، وهو ليس مجانياً في كل العالم، ولكن صفحة الحصول على SNOMED CT تنص على أن المنظمة لا تأخذ رسوماً على استخدامه في الدول الأعضاء، وقائمة الأعضاء فيها المملكة العربية السعودية. ومع ذلك فأنت تحتاج رخصة ارتباط Affiliate License وتسجيل استخدامك لدى مركز الإصدار الوطني في دولتك، فلا تنزله من أي مكان وتستخدمه.
  • ICD: التصنيف الدولي للأمراض من منظمة الصحة العالمية، ويستخدم للإحصاء والفوترة أكثر من التوثيق السريري الدقيق، وآخر إصدار له ICD-11 دخل حيز التنفيذ في 1 يناير 2022 بحسب المنظمة، ولكن أغلب الأنظمة ما زالت على ICD-10 أو تعديلاته الوطنية، ومنها ICD-10-AM الأسترالي الذي تجده في قائمة القيم الخاصة بمنصة نفيس كما سيأتي.
  • UCUM: نظام للوحدات تستطيع الآلة فهمه، فالوحدة mg/dL فيه لها تعريف دقيق يسمح بالتحويل إلى mmol/L بدل نص حر يكتبه كل مختبر بطريقته.

السياق السعودي من المصادر الرسمية

نذكر هنا فقط ما وجدناه منشوراً في مصادر رسمية عند كتابة المقال:

  • منصة نفيس NPHIES: بحسب موقعها الرسمي فهي منصة حكومية تربط مقدمي الخدمات الصحية بشركات التأمين، ومن خدماتها التحقق من أهلية المستفيد والموافقات المسبقة والمطالبات. والمنصة تابعة لمجلس الضمان الصحي CHI.
  • دليل التنفيذ: نشرت المنصة دليل التنفيذ للخدمات المالية الصحية بالإصدار 1.0.0، وينص على أن التبادل مبني على FHIR R4.0.1، والدليل منشور باسم HL7 Saudi Arabia. واللافت للمطور أن الدليل لا يستخدم واجهة REST التي استخدمناها في المختبر، وإنما رسائل FHIR Messaging: حزمة Bundle من نوع رسالة في أولها المورد MessageHeader ورمز الحدث، كما في صفحة بنية الرسالة، والسبب أن المطالبة حدث يمر بين أطراف ويحتاج إقراراً وتتبعاً، وليس مورداً يقرأه تطبيق. وفي قائمة مكونات الدليل تجد قائمة قيم ICD-10-AM للتشخيصات.
  • تبادل المعلومات الصحية الوطني: المركز الوطني للمعلومات الصحية له موقع رسمي، ولكننا لم نجد عند كتابة المقال وثيقة مواصفات تقنية منشورة للعموم عن منصة التبادل الصحي الوطنية أو دليل تنفيذ FHIR وطني عام للسجلات السريرية نستطيع أن نحيلك إليه، لذلك لا نذكر تفاصيلها هنا، فإذا كان مشروعك سوف يتصل بها فاطلب المواصفات من الجهة نفسها.

الخصوصية: البيانات الصحية بيانات حساسة

نظام حماية البيانات الشخصية يعرف البيانات الصحية بأنها كل بيان شخصي يتعلق بحالة الفرد الصحية الجسدية أو العقلية أو النفسية أو بالخدمات الصحية الخاصة به، ويضعها ضمن البيانات الحساسة، وتنص مادته الثالثة والعشرون على أن تحدد اللوائح ضوابط معالجتها ومنها قصر الاطلاع على الملفات الطبية على أقل عدد ممكن من العاملين وبالقدر اللازم فقط. وهذا يترجم في التصميم إلى ثلاثة أشياء: نطاقات SMART ضيقة، وصلاحيات على مستوى المريض لا على مستوى النظام، وسجل تدقيق يجيب عن «من قرأ ملف هذا المريض ومتى» كما شرحنا في مقال سجل التدقيق. وFHIR نفسه فيه المورد AuditEvent لهذا الغرض.

من المختبر إلى الإنتاج

  • لا تنشر السيرفر كما هو: HAPI في المختبر يقبل أي طلب من أي أحد، فضعه خلف بوابة تتحقق من توكن SMART أو فعل طبقة الأمان فيه، ولا تفتح المنفذ للشبكة العامة.
  • قاعدة بيانات حقيقية: بدل H2 في الذاكرة استخدم PostgreSQL مع نسخ احتياطي واختبار استعادة، فالسجل الطبي لا يقبل خسارة يوم.
  • التحقق عند الكتابة: فعل requests_enabled وحمل أدلة التنفيذ التي تلتزم بها، واربط خادم مصطلحات، فقد رأيت أن السيرفر الافتراضي حفظ ملاحظة دون رمز.
  • المعرفات: استخدم الإنشاء المشروط بالمعرف لكل مريض وكل طلب يأتي من نظام آخر، وإلا تكررت السجلات.
  • الإصدارات: كل تكامل يجب أن يعلن إصدار FHIR وإصدار الدليل، لأن R4 وR5 يختلفان في بعض الموارد.

تنظيف المختبر

docker rm -f lab-health-hapi lab-health-hapi-strict lab-health-py
docker network rm lab-health-net
docker image rm hapiproject/hapi:v8.12.0-2

تدريب عملي

  • أرسل نفس رسالة ORU مرة ثانية بالسكربت، ثم ابحث عن نتائج البوتاسيوم للمريضة، وفكر: كيف تمنع تكرار الملاحظات نفسها كما منعت تكرار المريضة؟ (تلميح: رقم الطلب في OBR-3.)
  • عدل نتيجة السكر 1002 ب PUT إلى 99، ثم اقرأ /Observation/1002/_history وقارن النسختين.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • المريض نفسه موجود في أنظمة كثيرة، والتكامل يتم برسائل HL7 v2 القديمة الواسعة الانتشار أو بواجهات FHIR الحديثة، وغالباً بالاثنين معاً عبر محرك واجهات.
  • رسالة HL7 v2 أسطر وحقول بفواصل ثابتة، وموقع كل قيمة برقم الحقل، وكل نظام يملأ الاختياري بطريقته.
  • FHIR موارد صغيرة بمراجع بينها وواجهة REST وبحث بمعاملات قياسية، والمعرفات والرموز تكتب دائماً مع نظامها.
  • HAPI بإعداده الافتراضي حفظ ملاحظة دون رمز رغم أن $validate وجد الخطأ، وبعد تفعيل التحقق رفضها ب 422، والتحقق البنيوي لا يعرف إن كان رمز LOINC موجوداً فعلاً.
  • الإنشاء المشروط منع تكرار المريضة عند تحويل رسالة HL7 v2 إلى FHIR.
  • SMART on FHIR يقيد التوكن بمريض واحد وبنطاقات بلغة الموارد، مع PKCE بطريقة S256 إلزامياً.
  • منصة نفيس تستخدم رسائل FHIR R4 بدليل تنفيذ منشور، والبيانات الصحية حساسة بنص نظام حماية البيانات الشخصية.
دليلكيف تعمل أنظمة الصور الطبية؟ DICOM وPACS من الداخلكيف تحفظ المستشفيات صور الأشعة وتنقلها بمعيار DICOM، وكيف تشغل أرشيفاً وعارضاً على سيرفرك وتجربهما على صور حقيقية.دليلكيف تعمل شركة ناشئة في الذكاء الاصطناعي الطبي؟ما الذي تحتاجه شركة ذكاء اصطناعي للأشعة غير النموذج: البيانات والتحقق والموافقة والتكامل مع المستشفى، بحسابات حقيقية.دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل OAuth ذلك بتوكنات محدودة، وكيف يضيف OpenID Connect الدخول الموحد.دليلكيف تصمم API لا تضطر إلى كسره بعد سنةمبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.دليلAudit Trail: كيف تبني سجل تدقيق لا يقبل العبثكيف تصمم سجلاً يجيب عن سؤال من غير هذه الصلاحية ومتى، وتحميه من الحذف والتعديل بعد كتابته.دليلالطابور Queue للمبتدئين: لماذا تحتاجه وكيف يعمللماذا تضع العمل البطيء في طابور بدل أن ينتظره المستخدم، وكيف تضمن ألا تضيع الرسالة إذا توقف ال Worker فجأة.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ المختبر على HAPI FHIR 8.12.0 (hapiproject/hapi:v8.12.0-2) بإصدار FHIR R4 4.0.1، مع python:3.13.16-slim وrequests 2.34.2 وjq 1.8.1، على Docker Engine 29.8.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.