في مقال أنظمة الصور الطبية رأينا أن الصورة تتحرك في المستشفى بمعيار 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 وهو النظام الأكبر الذي يشمل السجل والتسجيل والفوترة والمواعيد. وفي الواقع يستخدم الناس الكلمات الثلاث بالتبادل، والمهم لك كمطور هو السؤال: أين النسخة الأصلية من كل معلومة، ومن يرسلها لمن؟ والصورة التالية تبين الأنظمة المعتادة حول السجل:

لاحظ الصندوق الذي اسمه محرك الواجهات 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 في المختبر، والصورة التالية تفككها:

وقد يتساءل البعض: إذا كان 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 في المختبر لأن الدليل السعودي الذي نذكره لاحقاً مبني عليه أيضاً.

تجهيز المختبر: 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.jsonHTTP/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 في قسم لاحق:

نكسر المورد: من يكتشف الخطأ ومن لا يكتشفه؟
هذا أهم قسم في المختبر، لأن الخطأ في البيانات الصحية لا يظهر في الشاشة وإنما يظهر بعد أشهر عندما لا يجد تقرير أو تطبيق ما يبحث عنه. نبدأ بمورد فيه ثلاثة أخطاء: حالة غير موجودة 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.jsonHTTP 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.hl7message 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 مكتوبة بلغة الموارد:

- الاكتشاف: كل سيرفر 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: يرمز ما تم قياسه أو سؤاله، مثل
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.