الكتابات/tutorial/2026/08
Tutorial7 أغسطس 2026·32 دقيقة

بناء تكامل نفيس FHIR بلغة TypeScript: الأهلية والمطالبات وتتبّع الرفض

دليل عملي للتكامل مع منصة نفيس السعودية باستخدام TypeScript. ابنِ حزم رسائل FHIR R4 بأنواع صارمة، وأرسل طلبات الأهلية والمطالبات، واستعلم عن نتائج التسوية المؤجلة، واحفظ بيانات الرفض التي تتجاهلها لوحة تحكّم مزوّدك.

معظم المحتوى المنشور عن التكامل مع نفيس في السعودية مكتوب من شركات تبيع لك نظام معلومات مستشفيات. يشرح لك لماذا تحتاج نفيس، ثم يتوقّف عند النقطة التي تبدأ عندها الهندسة الفعلية. هذا الدليل يبدأ من تلك النقطة.

سنبني عميلًا مكتوبًا بـ TypeScript لواجهة رسائل نفيس: التحقّق من الأهلية، وإرسال المطالبات، والاستعلام عن التسوية المؤجلة، ثم الجزء الذي تتخطّاه أغلب تطبيقات المزوّدين — سجلّ رفض يحتفظ بأسباب الرفض بدلًا من التخلّص منها.

نحن لا نبيع نظام سجلات طبية ولا اشتراك غرفة مقاصّة، لذلك لا يوجد في هذا الدليل منتج نوجّهك إليه. هذه طبقة التكامل، مكتوبة بالطريقة التي يحتاج المهندس أن يقرأها بها.

ما الذي ستبنيه

خدمة Node.js تقوم بالتالي:

  1. بناء حزم رسائل FHIR R4 صحيحة لنفيس مع أنواع TypeScript كاملة
  2. نقلها عبر TLS متبادل إلى نقطة النهاية $process-message
  3. التمييز الصحيح بين الطبقات الثلاث المستقلّة التي قد تفشل عندها المعاملة
  4. إرسال طلب أهلية وقراءة نتيجة أهلية الموقع
  5. إرسال مطالبة والتعامل مع حالة التسوية queued عبر حلقة استعلام
  6. حفظ كل رمز رفض في سجلّ قابل للاستعلام

في النهاية سيكون لديك عميل يجيب على السؤال الوحيد الذي يهمّ الإدارة المالية: مما أرسلناه، ما الذي دُفع، وما الذي رُفض، ولماذا.

المتطلّبات المسبقة

قبل البدء، تأكّد من توفّر:

  • Node.js 20+ و TypeScript 5.4+
  • إلمام بـ async/await وبعملاء الواجهات المكتوبة بأنواع صارمة
  • معرفة أساسية بـ FHIR — يجب أن تعرف ما هو الـ Resource وما هي الـ Bundle. إن لم تكن كذلك، اقرأ نظرة عامة على FHIR R4 أولًا؛ فنفيس مبني على FHIR 4.0.1
  • بيانات الاعتماد من عملية التأهيل مع مجلس الضمان الصحي — الشهادة، واسم مضيف نقطة النهاية، ومعرّفات ترخيص المنشأة

بخصوص بيانات الاعتماد. أسماء مضيفات نفيس الأساسية، وروابط البيئة التجريبية، وشهادات العميل تُصدر لك عبر عملية التأهيل لدى مجلس الضمان الصحي. وهي ليست عامة، ولا يمكن لأي دليل أن يمنحك إياها. كل الأمثلة البرمجية هنا تقرأ هذه القيم من متغيّرات البيئة. وحين أشير إلى قيمة يجب أن تحصل عليها بنفسك، أقول ذلك صراحةً بدلًا من اختلاق قيمة وهمية تبدو حقيقية.

أما دليل تطبيق نفيس العام فهو يوثّق بنى الرسائل نفسها، وهو الأساس الذي بُني عليه الكود أدناه.

الخطوة 1: افهم نموذج الرسائل قبل كتابة أي كود

هذه هي الخطوة التي يتخطّاها الناس، وهي السبب في أن تكاملهم يستغرق أربعة أشهر.

نفيس ليست واجهة REST. لا يوجد POST /claims ولا GET /claims/123. توجد عمليًا عملية واحدة — هي $process-message في FHIR — ترسل إليها حزمة نوعها message. ونوع المعاملة تحدّده قيمة eventCoding في الـ MessageHeader داخل تلك الحزمة، لا الرابط.

فالشكل ثابت دائمًا:

POST <base>/$process-message
  Bundle (type: message)
    ├── MessageHeader        ← يجب أن يكون العنصر الأول؛ يحمل eventCoding
    ├── CoverageEligibilityRequest | Claim | Task | ...
    ├── Patient
    ├── Coverage
    ├── Organization (مقدّم الخدمة)
    └── Organization (شركة التأمين)

يترتّب على ذلك ثلاث نتائج مباشرة، وكل واحدة منها خطأ ستقع فيه إن أهملتها:

يجب أن يكون MessageHeader العنصر الأول في الحزمة. ليس "في مكان ما من الحزمة". فإن كنت تبني مصفوفة العناصر عبر map على مجموعة موارد، فستعيد ترتيبها يومًا ما وتحصل على رفض يبدو وكأنه خطأ في البنية.

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

المراسلة تعمل بنمط التخزين وإعادة التوجيه، لا بنمط طلب-استجابة. نفيس تتحقّق من معاملتك وتوجّهها. قد تسلّمها في الوقت الفعلي، وقد تخزّنها لتسليمها عندما يصبح نظام شركة التأمين متاحًا. الأهلية حالة استخدام فورية؛ أما تسوية المطالبات فغالبًا ليست كذلك. وإذا افترض عميلك أن استجابة HTTP تحتوي على الجواب الفعلي، فسيكون مخطئًا في نسبة كبيرة من المطالبات.

النقطة الثالثة هي التي تكلّف مالًا، والخطوة 5 تعالجها كما ينبغي.

الخطوة 2: إعداد المشروع والمغلّف المكتوب بأنواع

أنشئ المشروع:

mkdir nphies-client && cd nphies-client
npm init -y
npm install undici zod pino
npm install -D typescript tsx @types/node
npx tsc --init --target es2022 --module node16 --strict

نستخدم undici لأننا نحتاج تحكّمًا دقيقًا بشهادة عميل TLS، وzod للتحقّق من الاستجابات عند الحدود، وpino للسجلات المهيكلة — وهي ليست اختيارية في تكامل يعمل دون إشراف.

ابدأ بالمصطلحات. أنظمة ترميز نفيس تقع تحت المجال http://nphies.sa/terminology/، وكتابة هذه النصوص يدويًا في كل مكان هو الطريق الذي تتحوّل به الأخطاء المطبعية إلى حوادث إنتاج:

// src/terminology.ts
 
/** المجالات المعيارية لنفيس، وفق دليل تطبيق الخدمات المالية الصحية. */
export const NPHIES = {
  CS: 'http://nphies.sa/terminology/CodeSystem',
  SD: 'http://nphies.sa/fhir/ksa/nphies-fs/StructureDefinition',
} as const;
 
/** رموز أحداث الرسائل — وهي التي تحدّد نوع المعاملة. */
export const MessageEvent = {
  EligibilityRequest: 'eligibility-request',
  EligibilityResponse: 'eligibility-response',
  PriorAuthRequest: 'priorauth-request',
  PriorAuthResponse: 'priorauth-response',
  ClaimRequest: 'claim-request',
  ClaimResponse: 'claim-response',
  PollRequest: 'poll-request',
  PollResponse: 'poll-response',
} as const;
 
export type MessageEventCode =
  (typeof MessageEvent)[keyof typeof MessageEvent];

الآن بانِي المغلّف. هذه أثمن قطعة كود في المشروع، لأنها تجعل قاعدة "MessageHeader أولًا" مستحيلة الخرق بنيويًا:

// src/envelope.ts
import { NPHIES, type MessageEventCode } from './terminology.js';
 
export interface ParticipantConfig {
  /** ترخيص المنشأة، أي القيمة الصادرة عند التأهيل. */
  providerLicense: string;
  providerBaseUrl: string;
  /** معرّف ترخيص شركة التأمين لهذه المعاملة. */
  insurerLicense: string;
}
 
interface BundleEntry {
  fullUrl: string;
  resource: Record<string, unknown>;
}
 
/**
 * يبني حزمة رسالة نفيس.
 *
 * يُولَّد MessageHeader داخليًا ويُوضع دائمًا في المقدّمة،
 * فلا يستطيع المستدعي إعادة ترتيبه عن طريق الخطأ.
 */
export function buildMessageBundle(opts: {
  event: MessageEventCode;
  /** المورد الذي يشير إليه MessageHeader عبر focus. */
  focus: BundleEntry;
  /** كل مورد داعم: Patient و Coverage والمنظمات وغيرها. */
  supporting: BundleEntry[];
  participants: ParticipantConfig;
  bundleId: string;
  timestamp: string;
}) {
  const { event, focus, supporting, participants, bundleId, timestamp } = opts;
  const headerUrl = `urn:uuid:${bundleId}-hdr`;
 
  const messageHeader: BundleEntry = {
    fullUrl: headerUrl,
    resource: {
      resourceType: 'MessageHeader',
      id: `${bundleId}-hdr`,
      meta: { profile: [`${NPHIES.SD}/message-header`] },
      eventCoding: {
        system: `${NPHIES.CS}/ksa-message-events`,
        code: event,
      },
      destination: [
        {
          endpoint: `http://nphies.sa/license/payer-license/${participants.insurerLicense}`,
          receiver: {
            type: 'Organization',
            identifier: {
              system: 'http://nphies.sa/license/payer-license',
              value: participants.insurerLicense,
            },
          },
        },
      ],
      sender: {
        type: 'Organization',
        identifier: {
          system: 'http://nphies.sa/license/provider-license',
          value: participants.providerLicense,
        },
      },
      source: { endpoint: participants.providerBaseUrl },
      // focus يخبر المستقبِل بالمورد الذي هو موضوع الرسالة
      focus: [{ reference: focus.fullUrl }],
    },
  };
 
  return {
    resourceType: 'Bundle' as const,
    id: bundleId,
    meta: { profile: [`${NPHIES.SD}/bundle`] },
    type: 'message' as const,
    timestamp,
    // يُوضع الرأس في المقدّمة هنا، دون شرط.
    entry: [messageHeader, focus, ...supporting],
  };
}

لاحظ ضمان الترتيب في السطر الأخير. المستدعي يمرّر focus وsupporting منفصلين ولا يلمس مصفوفة العناصر أبدًا، فيبقى الشرط قائمًا مهما تطوّر الكود المستدعي.

بخصوص توليد المعرّفات. استخدم crypto.randomUUID() لمعرّفات الحزم، واحفظ القيمة التي ولّدتها. فحين تحتاج إلى تتبّع مطالبة عبر دعم نفيس بعد ستة أسابيع، سيكون معرّف الحزمة هو ما يُطلب منك. والمعرّف الذي لم تحفظه هو معرّف لم تولّده أصلًا.

الخطوة 3: طبقة النقل

اتصالات نفيس تستخدم شهادة عميل. مع undici تضبط ذلك مرّة واحدة في Agent وتعيد استخدامه:

// src/transport.ts
import { Agent, request } from 'undici';
import { readFileSync } from 'node:fs';
import pino from 'pino';
 
const log = pino({ name: 'nphies-transport' });
 
const agent = new Agent({
  connect: {
    cert: readFileSync(requireEnv('NPHIES_CLIENT_CERT_PATH')),
    key: readFileSync(requireEnv('NPHIES_CLIENT_KEY_PATH')),
    // لا تعطّل التحقّق من الشهادة أبدًا، ولا حتى في البيئة
    // التجريبية. إذا فشلت المصافحة، أصلح سلسلة الثقة.
    rejectUnauthorized: true,
  },
  // التخزين وإعادة التوجيه يعني أن بطء الاستجابة أمر طبيعي لا استثنائي.
  headersTimeout: 60_000,
  bodyTimeout: 60_000,
});
 
function requireEnv(name: string): string {
  const v = process.env[name];
  if (!v) throw new Error(`Missing required environment variable: ${name}`);
  return v;
}
 
export interface TransportResult {
  status: number;
  body: unknown;
}
 
export async function processMessage(
  bundle: unknown,
  correlationId: string,
): Promise<TransportResult> {
  const base = requireEnv('NPHIES_BASE_URL');
  const started = Date.now();
 
  const res = await request(`${base}/$process-message`, {
    dispatcher: agent,
    method: 'POST',
    headers: {
      'content-type': 'application/fhir+json',
      accept: 'application/fhir+json',
    },
    body: JSON.stringify(bundle),
  });
 
  const body = await res.body.json().catch(() => null);
 
  log.info(
    { correlationId, status: res.statusCode, ms: Date.now() - started },
    'process-message completed',
  );
 
  return { status: res.statusCode, body };
}

قراران يستحقّان الدفاع عنهما هنا.

المهل الزمنية سخيّة عن قصد. مهلة عشر ثوانٍ ستنتج إخفاقات متقطّعة في معاملات كانت سليمة فعلًا — والأسوأ أنك لن تعرف ما إذا كانت المعاملة قد وصلت. المهلة الزمنية غامضة بطبيعتها: لا تعاملها أبدًا كإرسال فاشل وتعيد المحاولة بشكل أعمى، وإلا أنشأت مطالبات مكرّرة.

تبقى rejectUnauthorized بقيمة true. كل فريق تكامل يصطدم بخطأ مصافحة في البيئة التجريبية، ثم يقترح أحدهم تعطيل التحقّق "فقط للاختبار". وهذا الخيار له عادة في الوصول إلى الإنتاج. أصلح سلسلة الثقة بدلًا من ذلك.

الخطوة 4: التحقّق من الأهلية

الأهلية هي المعاملة الأولى الصحيحة: فورية، ومنخفضة المخاطر، وتختبر خطّ الأنابيب بأكمله.

يحمل CoverageEligibilityRequest حقل purpose، والقيم الثلاث تعني أشياء مختلفة فعلًا:

الغرضما الذي تسأل عنهالاستخدام المعتاد
validationهل التغطية سارية في تاريخ الخدمة؟تسجيل الدخول في الاستقبال
benefitما المنافع والحدود المتبقّية؟قبل إجراء مكلف
discoveryما التغطيات النشطة لهذا المريض أصلًا؟المريض لا يملك بطاقة

الخطأ هنا شائع ومكلف: ترسل الفرق validation ثم تتساءل لماذا لا تحتوي الاستجابة على حدود المنافع. إنها لا تحتويها لأنك لم تطلبها.

// src/eligibility.ts
import { randomUUID } from 'node:crypto';
import { buildMessageBundle, type ParticipantConfig } from './envelope.js';
import { MessageEvent, NPHIES } from './terminology.js';
import { processMessage } from './transport.js';
 
export type EligibilityPurpose = 'validation' | 'benefit' | 'discovery';
 
export async function checkEligibility(input: {
  purpose: EligibilityPurpose;
  /** رقم الهوية الوطنية أو الإقامة للمريض. */
  patientIdentifier: string;
  patientId: string;
  coverageId: string;
  memberId: string;
  /** تاريخ ISO، مثل "2026-08-07" — تاريخ الخدمة. */
  servicedDate: string;
  participants: ParticipantConfig;
}) {
  const bundleId = randomUUID();
  const reqUrl = `urn:uuid:${randomUUID()}`;
  const patientUrl = `urn:uuid:${randomUUID()}`;
  const coverageUrl = `urn:uuid:${randomUUID()}`;
 
  const focus = {
    fullUrl: reqUrl,
    resource: {
      resourceType: 'CoverageEligibilityRequest',
      id: bundleId,
      meta: { profile: [`${NPHIES.SD}/eligibility-request`] },
      identifier: [
        {
          system: `${input.participants.providerBaseUrl}/eligibility`,
          value: bundleId,
        },
      ],
      status: 'active',
      // purpose مصفوفة — قد تطلب أكثر من غرض واحد بشكل مشروع.
      purpose: [input.purpose],
      patient: { reference: patientUrl },
      servicedDate: input.servicedDate,
      created: new Date().toISOString(),
      provider: {
        identifier: {
          system: 'http://nphies.sa/license/provider-license',
          value: input.participants.providerLicense,
        },
      },
      insurer: {
        identifier: {
          system: 'http://nphies.sa/license/payer-license',
          value: input.participants.insurerLicense,
        },
      },
      insurance: [{ coverage: { reference: coverageUrl } }],
    },
  };
 
  const supporting = [
    {
      fullUrl: patientUrl,
      resource: {
        resourceType: 'Patient',
        id: input.patientId,
        meta: { profile: [`${NPHIES.SD}/patient`] },
        identifier: [
          {
            type: {
              coding: [
                {
                  system: 'http://terminology.hl7.org/CodeSystem/v2-0203',
                  code: 'NI',
                },
              ],
            },
            system: 'http://nphies.sa/identifier/iqama',
            value: input.patientIdentifier,
          },
        ],
      },
    },
    {
      fullUrl: coverageUrl,
      resource: {
        resourceType: 'Coverage',
        id: input.coverageId,
        meta: { profile: [`${NPHIES.SD}/coverage`] },
        status: 'active',
        subscriberId: input.memberId,
        beneficiary: { reference: patientUrl },
        relationship: {
          coding: [
            {
              system:
                'http://terminology.hl7.org/CodeSystem/subscriber-relationship',
              code: 'self',
            },
          ],
        },
        payor: [
          {
            identifier: {
              system: 'http://nphies.sa/license/payer-license',
              value: input.participants.insurerLicense,
            },
          },
        ],
      },
    },
  ];
 
  const bundle = buildMessageBundle({
    event: MessageEvent.EligibilityRequest,
    focus,
    supporting,
    participants: input.participants,
    bundleId,
    timestamp: new Date().toISOString(),
  });
 
  return { bundleId, result: await processMessage(bundle, bundleId) };
}

موردا Patient و Coverage هنا مختصران إلى الحدّ الأدنى الذي يوضّح النمط. حزمة التأهيل الخاصة بك تحدّد عناصر إضافية مطلوبة — من بينها المهنة والحالة الاجتماعية وحقول الإقامة — وأداة التحقّق ستخبرك بدقّة أيها ناقص. حلقة التغذية الراجعة تلك سريعة؛ أما الفهم البنيوي فهو الجزء البطيء، وقد صار لديك الآن.

الخطوة 5: طبقات الفشل الثلاث — اقرأ هذا مرّتين

هذه أهم فكرة في التكامل مع نفيس، وهي السبب في أن كثيرًا من مقدّمي الخدمة لديهم لوحة تحكّم تعرض نجاحًا بنسبة 99% بينما يقول الحساب البنكي شيئًا آخر.

قد تفشل معاملة نفيس عند ثلاث طبقات مستقلّة.

الطبقةما تعنيهأين تظهر
1. النقلالرسالة لم تصل أصلًاحالة HTTP ليست 200
2. التحقّقنفيس رفضت بنية الرسالةحزمة الاستجابة تحتوي OperationOutcome
3. التسويةشركة التأمين رفضت الدفعClaimResponse.outcome وadjudication على مستوى البند

الطبقتان 1 و2 هما نفيس تخبرك عن رسالتك. أما الطبقة 3 فهي شركة التأمين تخبرك عن مالك. وهذان سؤالان مختلفان تمامًا، وعدد كبير من تكاملات الإنتاج يفحص الأولين فقط.

هذه هي الآلية وراء النمط الموصوف في التكامل مع نفيس لا يعني أنك ستُدفع — فمؤشّر النجاح التقني والنتيجة المالية يقيسان طبقتين مختلفتين.

وهناك حالة رابعة تُربك الناس: إذا لم تستطع نفيس تسليم رسالتك إلى شركة التأمين خلال دقيقة تقريبًا، فإنها تولّد استجابة بنفسها بدلًا من تركك معلّقًا. وتُوسَم تلك الاستجابة بعلامة على bundle.meta.tag لتمييزها عن ردّ شركة التأمين. عامِل الاستجابة المولّدة من نفيس على أنها "لا جواب بعد"، لا على أنها نتيجة تسوية.

رمّز هذا كلّه صراحةً:

// src/outcome.ts
 
export type TransactionOutcome =
  | { layer: 'transport'; ok: false; status: number }
  | { layer: 'validation'; ok: false; issues: ValidationIssue[] }
  | { layer: 'pending'; ok: true; reason: 'nphies-generated' | 'queued' }
  | { layer: 'adjudication'; ok: true; outcome: 'complete' | 'partial' }
  | { layer: 'adjudication'; ok: false; outcome: 'error'; denials: Denial[] };
 
export interface ValidationIssue {
  severity: string;
  code: string;
  details?: string;
}
 
export interface Denial {
  itemSequence?: number;
  code: string;
  display?: string;
}
 
interface FhirBundle {
  meta?: { tag?: Array<{ system?: string; code?: string }> };
  entry?: Array<{ resource?: Record<string, any> }>;
}
 
function findResource(bundle: FhirBundle, type: string) {
  return bundle.entry?.find((e) => e.resource?.resourceType === type)?.resource;
}
 
export function classifyOutcome(
  status: number,
  body: unknown,
): TransactionOutcome {
  if (status !== 200) return { layer: 'transport', ok: false, status };
 
  const bundle = body as FhirBundle;
 
  // الطبقة 2: نفيس رفضت الرسالة نفسها.
  const oo = findResource(bundle, 'OperationOutcome');
  if (oo) {
    return {
      layer: 'validation',
      ok: false,
      issues: (oo.issue ?? []).map((i: any) => ({
        severity: i.severity,
        code: i.code,
        details: i.details?.text,
      })),
    };
  }
 
  // الحالة الخاصة: نفيس ردّت نيابةً عن شركة التأمين.
  const isNphiesGenerated = bundle.meta?.tag?.some((t) =>
    t.system?.includes('nphies.sa'),
  );
  if (isNphiesGenerated) {
    return { layer: 'pending', ok: true, reason: 'nphies-generated' };
  }
 
  const claimResponse = findResource(bundle, 'ClaimResponse');
  if (!claimResponse) {
    // استجابات الأهلية وغير المطالبات تصل إلى هنا.
    return { layer: 'adjudication', ok: true, outcome: 'complete' };
  }
 
  // "queued" تعني أن التسوية لم تحدث بعد. استعلم عنها.
  if (claimResponse.outcome === 'queued') {
    return { layer: 'pending', ok: true, reason: 'queued' };
  }
 
  if (claimResponse.outcome === 'error') {
    return {
      layer: 'adjudication',
      ok: false,
      outcome: 'error',
      denials: extractDenials(claimResponse),
    };
  }
 
  return {
    layer: 'adjudication',
    ok: true,
    outcome: claimResponse.outcome === 'partial' ? 'partial' : 'complete',
  };
}
 
/** يستخرج أسباب الرفض من التسوية على مستوى البند وعلى مستوى الرأس معًا. */
export function extractDenials(claimResponse: any): Denial[] {
  const denials: Denial[] = [];
 
  for (const item of claimResponse.item ?? []) {
    for (const adj of item.adjudication ?? []) {
      for (const coding of adj.reason?.coding ?? []) {
        denials.push({
          itemSequence: item.itemSequence,
          code: coding.code,
          display: coding.display,
        });
      }
    }
  }
 
  // أخطاء مستوى الرأس تنطبق على المطالبة كاملة، لا على بند واحد.
  for (const err of claimResponse.error ?? []) {
    for (const coding of err.code?.coding ?? []) {
      denials.push({ code: coding.code, display: coding.display });
    }
  }
 
  return denials;
}

لاحظ أن extractDenials تقرأ كليهما: item[].adjudication[].reason وكذلك error[] على مستوى الرأس. فالتطبيقات التي تقرأ واحدًا منهما فقط تفقد صنفًا كاملًا من الرفض بصمت — وهو عادةً رفض مستوى الرأس، الذي يمثّل غالبًا المشكلات المنهجية القابلة للإصلاح والتي تؤثّر على كل مطالبة من نوع معيّن.

الخطوة 6: إرسال مطالبة

المطالبات هي المغلّف نفسه بنيويًا مع مورد محوري أغنى. الحقول التي تحمل المعنى:

  • type — مؤسّسي، مهني، أسنان، صيدلة، بصريات
  • subType — تنويم أو عيادات خارجية
  • useclaim للتعويض، وpreauthorization للموافقة المسبقة
  • diagnosis[] — مرمّزة بـ ICD-10-AM، مع تشخيص رئيسي واحد على الأقل
  • item[] — البنود القابلة للفوترة، لكل منها رمز خدمة وكمية وصافي مبلغ
  • supportingInfo[] — المرفقات والسياق السريري
// src/claim.ts
import { randomUUID } from 'node:crypto';
import { buildMessageBundle, type ParticipantConfig } from './envelope.js';
import { MessageEvent, NPHIES } from './terminology.js';
import { processMessage } from './transport.js';
import { classifyOutcome } from './outcome.js';
 
export interface ClaimLine {
  sequence: number;
  /** رمز الخدمة أو الإجراء من نظام الترميز المعتمد في نفيس. */
  code: string;
  codeSystem: string;
  quantity: number;
  unitPrice: number;
  /** أرقام تسلسل التشخيصات التي يُبرَّر بها هذا البند. */
  diagnosisSequence: number[];
}
 
export interface ClaimDiagnosis {
  sequence: number;
  /** رمز ICD-10-AM. */
  code: string;
  type: 'principal' | 'secondary';
}
 
export async function submitClaim(input: {
  patientRef: string;
  coverageRef: string;
  claimType: 'institutional' | 'professional' | 'pharmacy' | 'oral' | 'vision';
  subType: 'ip' | 'op';
  use: 'claim' | 'preauthorization';
  diagnoses: ClaimDiagnosis[];
  lines: ClaimLine[];
  supporting: Array<{ fullUrl: string; resource: Record<string, unknown> }>;
  participants: ParticipantConfig;
}) {
  const bundleId = randomUUID();
  const claimUrl = `urn:uuid:${randomUUID()}`;
 
  const total = input.lines.reduce(
    (sum, l) => sum + l.quantity * l.unitPrice,
    0,
  );
 
  const focus = {
    fullUrl: claimUrl,
    resource: {
      resourceType: 'Claim',
      id: bundleId,
      meta: { profile: [`${NPHIES.SD}/${input.claimType}-claim`] },
      identifier: [
        {
          system: `${input.participants.providerBaseUrl}/claim`,
          value: bundleId,
        },
      ],
      status: 'active',
      type: {
        coding: [
          {
            system: 'http://terminology.hl7.org/CodeSystem/claim-type',
            code: input.claimType,
          },
        ],
      },
      subType: {
        coding: [
          { system: `${NPHIES.CS}/claim-subtype`, code: input.subType },
        ],
      },
      use: input.use,
      patient: { reference: input.patientRef },
      created: new Date().toISOString(),
      insurer: {
        identifier: {
          system: 'http://nphies.sa/license/payer-license',
          value: input.participants.insurerLicense,
        },
      },
      provider: {
        identifier: {
          system: 'http://nphies.sa/license/provider-license',
          value: input.participants.providerLicense,
        },
      },
      priority: {
        coding: [
          {
            system: 'http://terminology.hl7.org/CodeSystem/processpriority',
            code: 'normal',
          },
        ],
      },
      diagnosis: input.diagnoses.map((d) => ({
        sequence: d.sequence,
        diagnosisCodeableConcept: {
          coding: [{ system: `${NPHIES.CS}/diagnosis-icd-10-am`, code: d.code }],
        },
        type: [
          {
            coding: [
              { system: `${NPHIES.CS}/diagnosis-type`, code: d.type },
            ],
          },
        ],
      })),
      insurance: [
        {
          sequence: 1,
          focal: true,
          coverage: { reference: input.coverageRef },
        },
      ],
      item: input.lines.map((l) => ({
        sequence: l.sequence,
        diagnosisSequence: l.diagnosisSequence,
        productOrService: {
          coding: [{ system: l.codeSystem, code: l.code }],
        },
        quantity: { value: l.quantity },
        unitPrice: { value: l.unitPrice, currency: 'SAR' },
        net: { value: l.quantity * l.unitPrice, currency: 'SAR' },
      })),
      total: { value: total, currency: 'SAR' },
    },
  };
 
  const bundle = buildMessageBundle({
    event:
      input.use === 'preauthorization'
        ? MessageEvent.PriorAuthRequest
        : MessageEvent.ClaimRequest,
    focus,
    supporting: input.supporting,
    participants: input.participants,
    bundleId,
    timestamp: new Date().toISOString(),
  });
 
  const { status, body } = await processMessage(bundle, bundleId);
  return { bundleId, outcome: classifyOutcome(status, body), raw: body };
}

احسب المجاميع، ولا تقبلها. يُشتقّ total من البنود أعلاه بدلًا من تمريره كمعامل. فعدم تطابق مجموع الرأس مع مجموع البنود من أكثر أسباب رفض التحقّق شيوعًا، واشتقاقه يجعل هذا الصنف من الأخطاء مستحيلًا.

الخطوة 7: الاستعلام عن التسوية المؤجّلة

حين تُرجع classifyOutcome القيمة pending، تكون المعاملة حيّة لكن دون جواب. توفّر نفيس معاملة استعلام لاسترجاع الاستجابات المنتظرة.

التطبيق الساذج — setInterval يستعلم كل 30 ثانية إلى الأبد — هو الطريق الذي تُقيَّد به معدّلات التكامل. استخدم تراجعًا أسّيًا محدودًا:

// src/poll.ts
import { randomUUID } from 'node:crypto';
import { buildMessageBundle, type ParticipantConfig } from './envelope.js';
import { MessageEvent, NPHIES } from './terminology.js';
import { processMessage } from './transport.js';
 
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
 
async function pollOnce(participants: ParticipantConfig) {
  const bundleId = randomUUID();
  const taskUrl = `urn:uuid:${randomUUID()}`;
 
  const focus = {
    fullUrl: taskUrl,
    resource: {
      resourceType: 'Task',
      id: bundleId,
      meta: { profile: [`${NPHIES.SD}/task`] },
      status: 'requested',
      intent: 'order',
      code: {
        coding: [{ system: `${NPHIES.CS}/task-code`, code: 'poll' }],
      },
      authoredOn: new Date().toISOString(),
      requester: {
        identifier: {
          system: 'http://nphies.sa/license/provider-license',
          value: participants.providerLicense,
        },
      },
      owner: {
        identifier: {
          system: 'http://nphies.sa/license/payer-license',
          value: participants.insurerLicense,
        },
      },
    },
  };
 
  const bundle = buildMessageBundle({
    event: MessageEvent.PollRequest,
    focus,
    supporting: [],
    participants,
    bundleId,
    timestamp: new Date().toISOString(),
  });
 
  return processMessage(bundle, bundleId);
}
 
/**
 * يستعلم بتراجع أسّي، مع سقف.
 *
 * يُرجع null عند نفاد الميزانية — وهي نتيجة مشروعة تعني
 * "لا جواب بعد"، وليست خطأً يُبتلع.
 */
export async function pollForResponses(
  participants: ParticipantConfig,
  opts: { maxAttempts?: number; baseDelayMs?: number } = {},
): Promise<unknown | null> {
  const maxAttempts = opts.maxAttempts ?? 6;
  const base = opts.baseDelayMs ?? 5_000;
 
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    if (attempt > 0) {
      // 5 ثوانٍ، 10، 20، 40، 80 — بسقف دقيقتين.
      const delay = Math.min(base * 2 ** (attempt - 1), 120_000);
      await sleep(delay);
    }
 
    const { status, body } = await pollOnce(participants);
    if (status === 200 && hasPayload(body)) return body;
  }
 
  return null;
}
 
function hasPayload(body: unknown): boolean {
  const bundle = body as { entry?: unknown[] };
  // استجابة استعلام تحتوي MessageHeader فقط تعني "لا شيء بالانتظار".
  return (bundle?.entry?.length ?? 0) > 1;
}

التفصيل الحاسم هو ما يحدث عند نفاد الميزانية. إرجاع null بمعنى "لا جواب بعد" صحيح، وهو ليس خطأً. فمطالبة لم تُسوَّ بعد خمس دقائق أمر طبيعي؛ إذ قد تستغرق التسوية أيامًا. ما يجب ألّا يحدث أبدًا هو أن يعامل كودك نفاد الاستعلام كفشل فيعيد إرسال المطالبة — فذلك ينشئ نسخًا مكرّرة، والنسخ المكرّرة تنشئ صنف رفض خاصًا بها.

للإنتاج، شغّل الاستعلام كمهمّة مجدولة على جدول المطالبات المعلّقة، لا كحلقة داخل الطلب.

الخطوة 8: سجلّ الرفض

كل ما سبق كان سباكة. هذه الخطوة هي التي يبدأ عندها التكامل بإنتاج شيء لم يكن متاحًا للعمل من قبل.

تقريبًا كل تطبيق مزوّد يقرأ ClaimResponse، ويضبط علامة الحالة إلى rejected، ثم يتخلّص من رموز السبب. بعدها تُعاد كتابة المطالبة يدويًا، ولا يستطيع أحد الإجابة على سؤال "أي سبب رفض يكلّفنا أكثر؟" — لأن البيانات أُلقيت في اللحظة التي وصلت فيها.

احتفظ بها. المخطّط ليس معقّدًا:

CREATE TABLE claim_submissions (
  bundle_id        UUID PRIMARY KEY,
  claim_identifier TEXT NOT NULL,
  patient_ref      TEXT NOT NULL,
  insurer_license  TEXT NOT NULL,
  submitted_at     TIMESTAMPTZ NOT NULL,
  total_sar        NUMERIC(12,2) NOT NULL,
  -- 'transport' | 'validation' | 'pending' | 'adjudication'
  outcome_layer    TEXT,
  outcome_code     TEXT,
  resolved_at      TIMESTAMPTZ
);
 
CREATE TABLE claim_denials (
  id             BIGSERIAL PRIMARY KEY,
  bundle_id      UUID NOT NULL REFERENCES claim_submissions(bundle_id),
  item_sequence  INT,               -- NULL لرفض مستوى الرأس
  denial_code    TEXT NOT NULL,
  denial_display TEXT,
  denied_amount  NUMERIC(12,2),
  recorded_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX idx_denials_code ON claim_denials(denial_code);

جدولان. هذا هو الفارق كلّه بين "لدينا الكثير من حالات الرفض" وبين قائمة أسباب مرتّبة ومسعّرة:

-- التقرير الذي لم تحصل عليه الإدارة المالية من قبل.
SELECT
  d.denial_code,
  d.denial_display,
  COUNT(*)                  AS occurrences,
  SUM(d.denied_amount)      AS sar_at_risk
FROM claim_denials d
JOIN claim_submissions s ON s.bundle_id = d.bundle_id
WHERE s.submitted_at >= now() - INTERVAL '90 days'
GROUP BY d.denial_code, d.denial_display
ORDER BY sar_at_risk DESC
LIMIT 20;

اربطه بمسار الإرسال:

// src/ledger.ts
import type { TransactionOutcome } from './outcome.js';
 
export async function recordOutcome(
  db: DbClient,
  bundleId: string,
  outcome: TransactionOutcome,
) {
  await db.query(
    `UPDATE claim_submissions
        SET outcome_layer = $2,
            outcome_code  = $3,
            resolved_at   = CASE WHEN $2 = 'pending' THEN NULL ELSE now() END
      WHERE bundle_id = $1`,
    [bundleId, outcome.layer, outcomeCode(outcome)],
  );
 
  if (outcome.layer === 'adjudication' && !outcome.ok) {
    for (const denial of outcome.denials) {
      await db.query(
        `INSERT INTO claim_denials
           (bundle_id, item_sequence, denial_code, denial_display)
         VALUES ($1, $2, $3, $4)`,
        [bundleId, denial.itemSequence ?? null, denial.code, denial.display],
      );
    }
  }
}
 
function outcomeCode(o: TransactionOutcome): string {
  if (o.layer === 'validation') return o.issues[0]?.code ?? 'unknown';
  if (o.layer === 'pending') return o.reason;
  if (o.layer === 'transport') return String(o.status);
  return o.outcome;
}

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

الخطوة 9: الاختبار دون اتصال حيّ

لن يتوفّر لك اتصال خلال معظم دورة التطوير. اِبنِ مقابل البنية بدلًا من ذلك — فشكل المغلّف مستقرّ وموثّق علنًا، ويمكن اختباره دون اتصال:

// tests/envelope.test.ts
import { describe, it, expect } from 'vitest';
import { buildMessageBundle } from '../src/envelope.js';
import { MessageEvent } from '../src/terminology.js';
 
const participants = {
  providerLicense: 'PR-FHIR-TEST',
  providerBaseUrl: 'http://provider.example.sa',
  insurerLicense: 'INS-FHIR-TEST',
};
 
describe('message bundle envelope', () => {
  it('always places MessageHeader first', () => {
    const bundle = buildMessageBundle({
      event: MessageEvent.ClaimRequest,
      focus: { fullUrl: 'urn:uuid:focus', resource: { resourceType: 'Claim' } },
      supporting: [
        { fullUrl: 'urn:uuid:p', resource: { resourceType: 'Patient' } },
      ],
      participants,
      bundleId: 'test-bundle',
      timestamp: '2026-08-07T09:00:00Z',
    });
 
    expect(bundle.entry[0].resource.resourceType).toBe('MessageHeader');
    expect(bundle.type).toBe('message');
  });
 
  it('points MessageHeader.focus at the focus resource', () => {
    const bundle = buildMessageBundle({
      event: MessageEvent.EligibilityRequest,
      focus: {
        fullUrl: 'urn:uuid:elig',
        resource: { resourceType: 'CoverageEligibilityRequest' },
      },
      supporting: [],
      participants,
      bundleId: 'test-bundle-2',
      timestamp: '2026-08-07T09:00:00Z',
    });
 
    const header = bundle.entry[0].resource as any;
    expect(header.focus[0].reference).toBe('urn:uuid:elig');
  });
});

اختبر classifyOutcome أيضًا مقابل عيّنات استجابة محفوظة. احفظ كل جسم استجابة حقيقي تستقبله أثناء التأهيل — فتلك العيّنات تصبح مجموعة اختبارات الانحدار لديك، وهي أثمن بكثير من أي شيء تصطنعه.

وللتحقّق البنيوي قبل توفّر الاتصال، شغّل أداة التحقّق الرسمية من HL7 FHIR مقابل حزمة دليل تطبيق نفيس. تلتقط مخالفات الملف الشخصي محليًا خلال ثوانٍ، مقابل رحلة ذهاب وإياب قد يستغرق ترتيبها يومًا كاملًا.

استكشاف الأخطاء

فشل التحقّق بخطأ مرجع. مورد مشار إليه عبر fullUrl غير موجود في الحزمة. الاكتفاء الذاتي صارم — تتبّع كل مرجع في مورد focus وتأكّد أنه يحلّ إلى عنصر ضمّنته فعلًا.

رُفض MessageHeader كغير صالح. تحقّق من eventCoding.system ومن أن الرمز يطابق المعاملة التي ترسلها. إرسال claim-request مع CoverageEligibilityRequest كمورد محوري يفشل، وهذا صحيح.

فشل مصافحة TLS. يكون السبب غالبًا سلسلة شهادات ناقصة لا شهادة خاطئة. تحقّق من السلسلة كاملة عبر openssl s_client -connect <host>:443 -showcerts. ولا تعطّل التحقّق.

رفض بسبب عدم تطابق المجموع. قيمة total في الرأس لا تساوي مجموع item[].net. وإن اتّبعت الخطوة 6، فهذا لا يمكن أن يحدث — لأن المجموع مشتقّ.

كل شيء ينجح لكن لا شيء يُدفع. أنت تفحص الطبقتين 1 و2 فقط. عد إلى الخطوة 5. هذا بفارق كبير أخطر أنماط الفشل في التكامل مع نفيس، وهو غير مرئي ما لم تبحث عنه عمدًا.

الخطوات التالية

  • أضف تسوية المدفوعات (أحداث payment-notice وpayment-reconciliation) لإغلاق الحلقة بين ما تمّت تسويته وما وصل فعلًا إلى الحساب
  • طبّق معاملات Communication ليُعالَج طلب شركة التأمين لمعلومات إضافية تلقائيًا بدل البريد الإلكتروني
  • اربط سجلّ الرفض بتقرير أسبوعي — الاستعلام المرتّب في الخطوة 8 كافٍ للبداية
  • إن كنت تعمل أيضًا تحت مظلّة الفوترة الإلكترونية لهيئة الزكاة، اقرأ أودو والمرحلة الثانية من زاتكا، فطبقتا الامتثال تتشاركان نموذج بيانات أكثر ممّا تتوقّع أغلب الفرق
  • وللحجّة المعمارية الأوسع حول إضافة طبقة تكامل فوق الأنظمة القائمة بدل استبدالها، انظر فخّ نظام تخطيط الموارد

الخلاصة

نموذج رسائل نفيس ليس صعبًا مفاهيميًا، لكنه لا يتسامح مع التفاصيل، وتقريبًا كل المواد المنشورة تتوقّف قبل الوصول إليها. ما بنيته هنا هو:

  • بانِي مغلّف يجعل قاعدة الترتيب غير قابلة للكسر بنيويًا
  • طبقة نقل بمهل زمنية صادقة ودون تعطيل للتحقّق
  • مصنّف نتائج يفصل بين النقل والتحقّق والتسوية — وهو التمييز الذي يحدّد ما إذا كان مؤشّر نجاحك حقيقيًا
  • سجلّ رفض يحوّل حالات الرفض من إزعاج متكرّر إلى قائمة أسباب مرتّبة قابلة للإصلاح

القطعة الأخيرة هي التي تغيّر الحوار. فمقدّم خدمة يستطيع تسمية أعلى خمسة رموز رفض لديه والريالات وراء كلّ منها يقف في موقع مختلف تمامًا عن مقدّم لا يعرف سوى أن "الكثير منها يُرفض".

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