الكتابات/tutorial/2026/08
● Tutorial26 أغسطس 2026·28 دقيقة

محرك أجر الإجازة المرضية بـ TypeScript — المادة 117

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

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

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

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

تصحيح، 23 سبتمبر 2026. احتوت نسخة سابقة من هذا الدرس على أربعة أخطاء، صُحّحت كلها أدناه. كانت تحسب السنة المرضية بالسنوات الميلادية، بينما تنص المادة 10 على أن جميع المدد في نظام العمل تُحسب بالتقويم الهجري ما لم ينص عقد العمل أو لائحة تنظيم العمل على خلاف ذلك، والسنة الهجرية تنتهي قبل الميلادية بنحو أحد عشر يومًا. وكانت تُخرج العمولة من الأجر، بينما تعرّف المادة 2 «الأجر» بأنه الأجر الفعلي وتذكر العمولة صراحةً في تعريفه. ونسبت الحماية من الفصل إلى المادة 117، والقاعدة في المادة 82. وكانت نقطة الدخول تُسقط الأيام المرضية الواقعة قبل حدّ نافذة داخل فترة الرواتب نفسها، فعلى أجر 10,500 ريال دُفع لتقرير واحد يعبر الحدّ 3,500 ريال بدل 5,950.

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

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

  • Node.js 20+ و TypeScript 5.5+ مثبّتين
  • إلمام بالتواريخ والحساب على الأعداد الصحيحة والاتحادات المميّزة في TypeScript
  • نظام رواتب أو موارد بشرية يحوي سجلًّا رئيسيًا للموظف يعرف مسبقًا نوع العقد والأجر الشهري ومكوّنات الأجر
  • إمكانية الوصول إلى سجلات الإجازات والحضور لديك — فالمحرك لا يتجاوز جودة بيانات الغياب التي تغذّيه
  • لا حاجة إلى معرفة قانونية سابقة؛ فالقواعد النظامية المهمّة مذكورة أثناء تنفيذها

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

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

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

  • بوابة نظامية تُصنّف علاقة العمل قبل أي شيء آخر، وترمي استثناءً بدل أن تخمّن
  • محدِّد قاعدة الأجر الذي يجيب عن السؤال الذي تركته المادة 117 مفتوحًا — أيّ أجر تقصده كلمة «الأجر»
  • نافذة سنة مرضية متحرّكة يفتحها أول يوم مرضي، لا شهر يناير، وتُحسب بتقويم العقد — الهجري ما لم ينص العقد على خلافه
  • سلّم الشرائح الثلاث يُستهلك يومًا بيوم عبر الغيابات المتصلة والمتقطعة
  • موجِّه الإصابات الذي يرسل إصابات العمل إلى فرع التأمينات الذي يدفعها فعلًا، دون أن يستهلك يومًا واحدًا من رصيد المادة 117
  • بوابة التقرير الطبي التي تشترط رمز إجازة مرضية قابلًا للتحقق قبل دفع أي يوم
  • حارس الفصل الذي يُظهر الأيام المحمية المتبقية كي لا يُنهي مسار إنهاء الخدمة لديك عقد شخص لا تزال المادة 82 تحميه

الخطوة 1: احسم النظام المطبَّق قبل أي شيء آخر

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

/**
 * Which statutory instrument governs this employment relationship.
 *
 * `labour-law` — a private-sector contract under نظام العمل. Article 117
 *   applies: 30 days full, 60 at three quarters, 30 unpaid, per sick year.
 *
 * `civil-service` — a government post under the public-sector human-resources
 *   regulations. A materially longer and differently tiered ladder applies.
 *   This engine does NOT implement it.
 */
export type Regime = 'labour-law' | 'civil-service';
 
export class RegimeNotSupportedError extends Error {
  constructor(public readonly regime: Regime) {
    super(
      `Sick leave under the ${regime} regime is not implemented by this engine. ` +
        `Article 117 of the Labour Law governs private-sector contracts only.`,
    );
    this.name = 'RegimeNotSupportedError';
  }
}
 
export function assertLabourLaw(regime: Regime): asserts regime is 'labour-law' {
  if (regime !== 'labour-law') throw new RegimeNotSupportedError(regime);
}

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

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

الخطوة 2: حدِّد قاعدة الأجر — من هنا يتسرّب المال

تقول المادة 117 إن للعامل الحق في إجازة مرضية «بأجر» عن الثلاثين يومًا الأولى، و«بثلاثة أرباع الأجر» عن الستين التالية. لكنها لا توضّح أيّ أجر.

ولا حاجة إلى ذلك. فالمادة 2 تعرّف أجرين ثم تحسم المسألة في سطر واحد: «الأجر: الأجر الفعلي». أي أن كلمة «الأجر» حيث وردت مطلقةً في النظام تعني الأجر الفعلي، وهو الأجر الأساسي مضافًا إليه سائر الزيادات المستحقة مقابل جهد أو مخاطر أو بموجب العقد. ثم يعدّد التعريف ما يدخل فيه، وأول ما يذكره العمولة والنسبة المئوية من المبيعات. ويشمل كذلك البدلات كبدل السكن والنقل، والمنح والمكافآت التي قرّرها العقد أو جرت العادة بها، والميزات العينية التي يلتزم صاحب العمل بتوفيرها. أما العمل الإضافي فيُسعَّر صراحةً من الأجر الأساسي مضافًا إليه خمسون بالمئة (المادة 107). والأنظمة التي تعيد استخدام قاعدة العمل الإضافي هنا تدفع ناقصًا عن كل يوم مرضي، غالبًا بنسبة ثلاثين إلى أربعين بالمئة. والأنظمة التي تقف عند الأساسي والبدلات تدفع ناقصًا لكل موظف بعمولة فوق ذلك. ولا يدقّق أحد هذا البند حتى يغادر موظف.

/** All money is integer halalas. 1 SAR = 100 halalas. Never floats. */
export type Halalas = number;
 
export type WageComponents = {
  basic: Halalas;
  housing: Halalas;
  transport: Halalas;
  /** Other fixed monthly pay: allowances, and in-kind benefits at their contract value. */
  otherRegular: Halalas;
  /**
   * Variable pay — commission, percentage of sales, piece rates. Part of the
   * actual wage under Article 2, averaged per day actually worked under
   * Article 96. `earned` over the reference period, `workedDays` in it.
   */
  variable: { earned: Halalas; workedDays: number };
};
 
export type WageBase = 'actual' | 'basic';
 
/** The fixed monthly part of the wage. Variable pay has no monthly figure. */
export function fixedMonthlyWage(w: WageComponents, base: WageBase): Halalas {
  if (base === 'basic') return w.basic;
  return w.basic + w.housing + w.transport + w.otherRegular;
}
 
/** Article 96: variable pay divided by the days actually worked to earn it. */
export function variableDailyAverage(w: WageComponents): number {
  const { earned, workedDays } = w.variable;
  if (earned === 0) return 0;
  if (!Number.isInteger(workedDays) || workedDays <= 0) {
    throw new RangeError('variable.workedDays must be a positive whole number');
  }
  return earned / workedDays;
}
 
/**
 * The daily rate. The fixed part is priced at a thirtieth of the month
 * (Article 2 defines the month as thirty days), whatever the calendar month
 * holds. The variable part is already a daily figure. Kept fractional on
 * purpose — see Step 4.
 */
export function dailyRate(w: WageComponents, base: WageBase): number {
  const fixed = fixedMonthlyWage(w, base) / 30;
  return base === 'basic' ? fixed : fixed + variableDailyAverage(w);
}

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

ليس للعمولة رقم شهري يُقسم، ولذلك تسعّرها المادة 96 بطريقة أخرى: ما تقاضاه العامل مقسومًا على أيام العمل الفعلية التي كسبه فيها. وفي الأجر بالقطعة أو الإنتاج تحدّد المادة فترة المرجع بالسنة الأخيرة من الخدمة. ونستخدم الاثني عشر شهرًا نفسها للعمولة، ونضيف المتوسط إلى المعدّل اليومي الثابت حين يكون الأجر ثابتًا في جزء وعمولةً في جزء. هذه الحالة المختلطة قراءتنا نحن، لأن المادة لا تتناول إلا الأجر الذي كله بالقطعة أو كله عمولات. وفي الحالتين سجِّل فترة المرجع مع النتيجة. والسكن الذي يُوفَّر عينًا بدل أن يُدفع بدلًا يبقى جزءًا من الأجر: أدخله في otherRegular بالقيمة التي يحدّدها العقد.

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

الخطوة 3: السنة المرضية متحرّكة وتبدأ حين يمرض الموظف

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

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

وأيّ سنة؟ هذا هو الفخّ الثاني. تنص المادة 10 على أن «تحسب جميع المدد والمواعيد المنصوص عليها في هذا النظام بالتقويم الهجري، ما لم ينص في عقد العمل أو لائحة تنظيم العمل على خلاف ذلك». والسنة الهجرية 354 أو 355 يومًا. فالنافذة التي فُتحت في 10 مارس 2026 تُغلق بالحساب الهجري في 27 فبراير 2027، لا في 9 مارس. ومن مرض مجددًا في 1 مارس 2027 يبدأ إذن شريحة أجر كامل جديدة، بينما يضعه المحرك الميلادي في شريحة ثلاثة الأرباع. لذلك فالتقويم حقل إلزامي في سجل الموظف، مثل النظام المطبَّق، ومصدره العقد.

التقسيم حسب السنة التقويمية يخطئ في الاتجاهين. موظف مرض خمسة وعشرين يومًا في ديسمبر وخمسة وعشرين في يناير يكون قد استهلك خمسين يومًا من سنة مرضية واحدة، ويُفترض أنه دخل شريحة الخمسة والسبعين بالمئة؛ لكن التقسيم التقويمي يدفع الفترتين بأجر كامل. وبالعكس، موظف فُتحت نافذته في مارس 2025 وأُغلقت في مارس 2026 يستحق شريحة أجر كامل جديدة في مارس، يحجبها عنه التقسيم التقويمي.

/** Article 10: Hijri unless the contract or the work regulations say otherwise. */
export type CalendarBasis = 'hijri' | 'gregorian';
 
export type SickYear = {
  /** Inclusive ISO date on which this window opened. */
  start: string;
  /** Inclusive ISO date on which it closes: the day before the same date a year on. */
  end: string;
  calendar: CalendarBasis;
};
 
const DAY_MS = 86_400_000;
 
type Ymd = readonly [number, number, number];
 
const HIJRI = new Intl.DateTimeFormat('en-u-ca-islamic-umalqura-nu-latn', {
  timeZone: 'UTC',
  year: 'numeric',
  month: 'numeric',
  day: 'numeric',
});
 
function toUTC(iso: string): number {
  const [y, m, d] = iso.split('-').map(Number);
  return Date.UTC(y, m - 1, d);
}
 
function toISO(ms: number): string {
  return new Date(ms).toISOString().slice(0, 10);
}
 
function partsOf(ms: number, calendar: CalendarBasis): Ymd {
  const date = new Date(ms);
  if (calendar === 'gregorian') {
    return [date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()];
  }
  const parts = HIJRI.formatToParts(date);
  const pick = (type: string) => Number(parts.find((p) => p.type === type)?.value);
  return [pick('year'), pick('month'), pick('day')];
}
 
const compare = (a: Ymd, b: Ymd) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
 
/** The latest day whose date is on or before `target`. `guess` only needs to be close. */
function lastDayOnOrBefore(target: Ymd, calendar: CalendarBasis, guess: number): number {
  let t = guess;
  while (compare(partsOf(t + DAY_MS, calendar), target) <= 0) t += DAY_MS;
  while (compare(partsOf(t, calendar), target) > 0) t -= DAY_MS;
  return t;
}
 
const YEAR_DAYS: Record<CalendarBasis, number> = { gregorian: 365, hijri: 354 };
 
export function openSickYear(firstSickDay: string, calendar: CalendarBasis): SickYear {
  const start = toUTC(firstSickDay);
  const [y, m, d] = partsOf(start, calendar);
  // The day before the same date one year on. Day 0 sorts before day 1, so a
  // window opened on the 1st closes on the last day of the previous month,
  // and a date the closing month lacks settles on that month's last day.
  const guess = start + (YEAR_DAYS[calendar] - 1) * DAY_MS;
  const end = lastDayOnOrBefore([y + 1, m, d - 1], calendar, guess);
  return { start: firstSickDay, end: toISO(end), calendar };
}
 
export function isWithin(year: SickYear, day: string): boolean {
  const t = toUTC(day);
  return t >= toUTC(year.start) && t <= toUTC(year.end);
}

ثلاثة تفاصيل تستحق التثبيت. اعمل بمنتصف ليل التوقيت العالمي في كل مكان — فمحرك رواتب يحترم المنطقة الزمنية المحلية سيُزيح يوم الحدّ بصمت حين يُنقل الخادم، ويوم مرضي يهبط قبل يوم واحد قد يدفع فترة كاملة إلى شريحة أخرى. وابنِ النافذة من التواريخ التقويمية، لا بإضافة 365 أو 354 يومًا، كي لا تقتطع سنة ميلادية كبيسة ولا شهر هجري من ثلاثين يومًا يومًا من استحقاق أحدهم. وIntl.DateTimeFormat مع islamic-umalqura يعطيك تقويم أم القرى، التقويم الرسمي في السعودية، من بيانات ICU المضمّنة في Node 20 — بلا مكتبة تواريخ. قارنّا كل نافذة فُتحت بين 2024 ومنتصف 2028 بتطبيق مستقل لتقويم أم القرى فتطابقت. ومن عام 1451هـ (أغسطس 2029) فصاعدًا يبدأ جدولا الأشهر في الاختلاف، فثبِّت إصدار ICU لديك وراجِع النوافذ البعيدة مقابل التقويم الرسمي.

الخطوة 4: استهلك السلّم يومًا بيوم

سلّم المادة 117 ينطبق «سواء أكانت هذه الإجازات متصلة أم متقطعة». إذن وحدة الحساب هي اليوم، والشريحة التي يقع فيها يوم بعينه تتوقف فقط على عدد الأيام المرضية المستهلكة أصلًا في النافذة الجارية.

export type Tier = {
  /** Cumulative day number, inclusive, at which this tier ends. */
  throughDay: number;
  numerator: number;
  denominator: number;
  label: string;
};
 
/** Article 117: 30 days full, the next 60 at three quarters, the next 30 unpaid. */
export const ARTICLE_117: readonly Tier[] = [
  { throughDay: 30, numerator: 1, denominator: 1, label: 'full pay' },
  { throughDay: 90, numerator: 3, denominator: 4, label: 'three quarters' },
  { throughDay: 120, numerator: 0, denominator: 1, label: 'unpaid' },
];
 
/**
 * Split `days` new sick days, starting after `alreadyUsed` days consumed in
 * this window, into per-tier slices. Days beyond 120 fall outside the
 * entitlement entirely and are returned separately.
 */
export function splitAcrossTiers(
  alreadyUsed: number,
  days: number,
): { slices: { tier: Tier; days: number }[]; beyondEntitlement: number } {
  const slices: { tier: Tier; days: number }[] = [];
  let cursor = alreadyUsed;
  let remaining = days;
 
  for (const tier of ARTICLE_117) {
    if (remaining <= 0) break;
    const roomInTier = tier.throughDay - cursor;
    if (roomInTier <= 0) continue;
    const take = Math.min(roomInTier, remaining);
    slices.push({ tier, days: take });
    cursor += take;
    remaining -= take;
  }
 
  return { slices, beyondEntitlement: remaining };
}

الآن سعِّر الشرائح. قرار التقريب أهمّ ممّا يبدو: ثلاثة أرباع المعدّل اليومي نادرًا ما تكون عددًا صحيحًا من الهللات، وتقريب كل يوم على حدة يراكم انحرافًا يبلغ عدة ريالات عبر فترة من ستين يومًا. اضرب أولًا، وقرِّب مرة واحدة لكل شريحة.

export type PaySlice = {
  tier: string;
  days: number;
  amount: Halalas;
};
 
export function priceSlices(
  slices: { tier: Tier; days: number }[],
  rate: number,
): PaySlice[] {
  return slices.map(({ tier, days }) => ({
    tier: tier.label,
    days,
    // Multiply across the whole slice, then round once. Rounding per day
    // drifts by several riyals over a 60-day spell.
    amount: Math.round((rate * days * tier.numerator) / tier.denominator),
  }));
}

الأيام التي تتجاوز اليوم المئة والعشرين ليست «إجازة مرضية دون أجر» — بل هي خارج استحقاق المادة 117 كليًّا. عامِلها كغياب غير مصرّح به ما لم يتّفق الطرفان على إجازة دون أجر بموجب المادة 116، وهي تستلزم موافقة صاحب العمل وتُشكّل سجلًّا مختلفًا بنتائج مختلفة. لا تدع المحرك يطمس هذا الفارق: أعِد beyondEntitlement ودع المستدعي يقرّر.

الخطوة 5: إصابة العمل ليست إجازة مرضية

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

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

export type AbsenceCause = 'illness' | 'occupational-injury';
 
export type Routed =
  | { route: 'article-117'; days: string[] }
  | { route: 'occupational-hazards'; days: string[]; note: string };
 
export function routeByCause(cause: AbsenceCause, days: string[]): Routed {
  if (cause === 'occupational-injury') {
    return {
      route: 'occupational-hazards',
      days,
      note:
        'Compensated through the occupational hazards branch of social insurance. ' +
        'Does NOT consume Article 117 balance and must not be paid as sick leave.',
    };
  }
  return { route: 'article-117', days };
}

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

الخطوة 6: لا تقرير موثّق، لا يوم مرضي مدفوع

استحقاق المادة 117 يخصّ العامل «الذي يثبت مرضه». والإثبات تقرير طبي من جهة معترف بها، وفي السعودية تُصدَر هذه التقارير ويُتحقّق منها رقميًّا — فالإجازات المرضية المعتمدة للموظف تظهر في منصة صحتي وتحمل رمز خدمة يستطيع صاحب العمل التحقق منه عبر منصة الخدمات الصحية الوطنية.

هذا يمنح الرواتب ضابطًا ينبغي أن يُطبَّق فعلًا: اليوم بلا رمز قابل للتحقق ليس يومًا مرضيًّا.

export type Certificate = {
  /** The sick leave service code issued with the certificate. */
  code: string;
  from: string;
  to: string;
  cause: AbsenceCause;
  /** Set only after checking the code against the issuing platform. */
  verified: boolean;
  verifiedAt?: string;
};
 
export class UnverifiedCertificateError extends Error {
  constructor(code: string) {
    super(
      `Sick leave certificate ${code} has not been verified. ` +
        `Days covered by it cannot be paid under Article 117.`,
    );
    this.name = 'UnverifiedCertificateError';
  }
}
 
export function assertVerified(cert: Certificate): void {
  if (!cert.verified) throw new UnverifiedCertificateError(cert.code);
}

خزِّن verifiedAt ومَن أجرى التحقق. حين تُنازَع تصفية بعد سنتين، تكون عبارة «تحقّقنا من الرمز في هذا التاريخ» دفاعًا، أما «المدير قال إنها سليمة» فلا.

قاوِم إغراء التحقق الآلي بكشط بوابة إلكترونية. التحقق إجراء متعمَّد ومسجَّل يقوم به إنسان أو حساب خدمة مقابل نقطة نهاية رسمية، ومكانه خلف طبقة التكامل لديك لا داخل حساب الأجر.

الخطوة 7: أظهِر حارس الفصل

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

المحرك الذي يُعيد المال فقط لا يكفي هنا. أعِد الأيام المحمية المتبقية كحقل من الدرجة الأولى، واجعل مسار إنهاء الخدمة لديك يقرأه.

export type Protection = {
  daysUsed: number;
  daysRemaining: number;
  /** Article 82: true while the statutory sick-leave periods are not yet exhausted. */
  protected: boolean;
  /** Null until a first sick day opens a window. */
  windowEnds: string | null;
};
 
const ENTITLEMENT_DAYS = 120;
 
export function protectionStatus(year: SickYear | null, daysUsed: number): Protection {
  const daysRemaining = Math.max(0, ENTITLEMENT_DAYS - daysUsed);
  return {
    daysUsed,
    daysRemaining,
    protected: daysRemaining > 0,
    windowEnds: year === null ? null : year.end,
  };
}

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

الخطوة 8: ركِّب بند كشف الراتب والسجلّ

كل ما سبق يتركّب في نقطة دخول واحدة.

export type Employee = {
  id: string;
  regime: Regime;
  wage: WageComponents;
  wageBase: WageBase;
  /** Required, no default: Hijri unless the contract says otherwise (Article 10). */
  calendar: CalendarBasis;
};
 
export type SickLeaveResult = {
  employeeId: string;
  period: { from: string; to: string };
  /** The window open at the end of the period. Persist it with `daysUsed`. */
  year: SickYear | null;
  daysUsed: number;
  slices: (PaySlice & { windowStart: string })[];
  total: Halalas;
  beyondEntitlement: number;
  routedToInsurance: string[];
  protection: Protection;
};
 
export function computeSickLeavePay(
  employee: Employee,
  certificates: Certificate[],
  period: { from: string; to: string },
  priorDaysUsed: number,
  openWindow: SickYear | null,
): SickLeaveResult {
  assertLabourLaw(employee.regime);
  certificates.forEach(assertVerified);
 
  const illness = new Set<string>();
  const injury = new Set<string>();
 
  for (const cert of certificates) {
    const days = expandDays(cert.from, cert.to).filter(
      (d) => d >= period.from && d <= period.to,
    );
    const routed = routeByCause(cert.cause, days);
    const target = routed.route === 'occupational-hazards' ? injury : illness;
    routed.days.forEach((d) => target.add(d));
  }
 
  // Walk the sick days in order. A day outside the current window opens a
  // new one; the days before it stay priced in the window they fell in.
  let year = openWindow;
  let used = openWindow === null ? 0 : priorDaysUsed;
  const segments: { year: SickYear; used: number; days: number }[] = [];
 
  for (const day of [...illness].sort()) {
    if (year === null || !isWithin(year, day)) {
      year = openSickYear(day, employee.calendar);
      used = 0;
      segments.push({ year, used, days: 0 });
    } else if (segments.length === 0) {
      segments.push({ year, used, days: 0 });
    }
    segments[segments.length - 1].days += 1;
    used += 1;
  }
 
  const rate = dailyRate(employee.wage, employee.wageBase);
  const slices: (PaySlice & { windowStart: string })[] = [];
  let beyondEntitlement = 0;
 
  for (const seg of segments) {
    const split = splitAcrossTiers(seg.used, seg.days);
    for (const s of priceSlices(split.slices, rate)) {
      slices.push({ ...s, windowStart: seg.year.start });
    }
    beyondEntitlement += split.beyondEntitlement;
  }
 
  return {
    employeeId: employee.id,
    period,
    year,
    daysUsed: used,
    slices,
    total: slices.reduce((sum, s) => sum + s.amount, 0),
    beyondEntitlement,
    routedToInsurance: [...injury].sort(),
    protection: protectionStatus(year, used),
  };
}
 
function expandDays(from: string, to: string): string[] {
  const out: string[] = [];
  for (let t = toUTC(from); t <= toUTC(to); t += DAY_MS) out.push(toISO(t));
  return out;
}

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

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

اختبار التنفيذ

الحالات التالية هي التي تلتقط أخطاءً حقيقية. اكتبها قبل أن تثق بالوحدة.

import { describe, expect, it } from 'vitest';
 
const wage: WageComponents = {
  basic: 800_000,      // 8,000 SAR
  housing: 200_000,    // 2,000 SAR
  transport: 50_000,   //   500 SAR
  otherRegular: 0,
  variable: { earned: 0, workedDays: 0 },
};
 
// 36,000 SAR of commission earned over 240 days actually worked.
const withCommission: WageComponents = {
  ...wage,
  variable: { earned: 3_600_000, workedDays: 240 },
};
 
const employee: Employee = {
  id: 'E-1',
  regime: 'labour-law',
  wage,
  wageBase: 'actual',
  calendar: 'gregorian',
};
 
const sick = (from: string, to: string): Certificate => ({
  code: `GSL-${from}`,
  from,
  to,
  cause: 'illness',
  verified: true,
  verifiedAt: '2026-01-01',
});
 
describe('Article 117 sick leave', () => {
  it('builds the fixed part of the actual wage from basic plus allowances', () => {
    expect(fixedMonthlyWage(wage, 'actual')).toBe(1_050_000);
    expect(fixedMonthlyWage(wage, 'basic')).toBe(800_000);
  });
 
  it('prices the month at 30 days regardless of the calendar', () => {
    // 10,500 SAR over 30 days = 350 SAR per day.
    expect(dailyRate(wage, 'actual')).toBe(35_000);
  });
 
  it('adds commission as a daily average over days actually worked', () => {
    // 350 SAR fixed + 36,000 / 240 = 150 SAR variable = 500 SAR per day.
    expect(dailyRate(withCommission, 'actual')).toBe(50_000);
    expect(dailyRate(withCommission, 'basic')).toBe(800_000 / 30);
  });
 
  it('refuses commission with no worked days to divide by', () => {
    const broken: WageComponents = { ...wage, variable: { earned: 100_000, workedDays: 0 } };
    expect(() => dailyRate(broken, 'actual')).toThrow(RangeError);
  });
 
  it('splits a 100-day spell across all three tiers', () => {
    const { slices, beyondEntitlement } = splitAcrossTiers(0, 100);
    expect(slices.map((s) => s.days)).toEqual([30, 60, 10]);
    expect(beyondEntitlement).toBe(0);
  });
 
  it('carries the tier cursor across intermittent spells', () => {
    // 25 days used in December, 25 more in January of the same sick year.
    const { slices } = splitAcrossTiers(25, 25);
    expect(slices.map((s) => [s.tier.label, s.days])).toEqual([
      ['full pay', 5],
      ['three quarters', 20],
    ]);
  });
 
  it('reports days beyond the 120-day entitlement separately', () => {
    const { beyondEntitlement } = splitAcrossTiers(115, 20);
    expect(beyondEntitlement).toBe(15);
  });
 
  it('closes a Hijri window one Hijri year after the first sick day', () => {
    // 21 Ramadan 1447 opens it; 20 Ramadan 1448 closes it.
    expect(openSickYear('2026-03-10', 'hijri')).toEqual({
      start: '2026-03-10',
      end: '2027-02-27',
      calendar: 'hijri',
    });
  });
 
  it('closes a Gregorian window one Gregorian year after the first sick day', () => {
    expect(openSickYear('2026-03-10', 'gregorian').end).toBe('2027-03-09');
  });
 
  it('handles a Gregorian leap year without losing a day', () => {
    expect(openSickYear('2027-03-01', 'gregorian').end).toBe('2028-02-29');
  });
 
  it('opens a new Hijri window where a Gregorian one would still be running', () => {
    const hijri = openSickYear('2026-03-10', 'hijri');
    const gregorian = openSickYear('2026-03-10', 'gregorian');
    expect(isWithin(hijri, '2027-03-01')).toBe(false);
    expect(isWithin(gregorian, '2027-03-01')).toBe(true);
  });
 
  it('prices days on both sides of a window boundary inside one period', () => {
    const openWindow = openSickYear('2025-06-01', 'gregorian'); // closes 2026-05-31
    const result = computeSickLeavePay(
      employee,
      [sick('2026-05-25', '2026-06-10')],
      { from: '2026-05-01', to: '2026-06-30' },
      20,
      openWindow,
    );
    // 7 days close the old window (days 21-27), 10 open the new one.
    expect(result.slices.map((s) => [s.windowStart, s.days])).toEqual([
      ['2025-06-01', 7],
      ['2026-06-01', 10],
    ]);
    expect(result.total).toBe(17 * 35_000);
    expect(result.year).toEqual(openSickYear('2026-06-01', 'gregorian'));
    expect(result.daysUsed).toBe(10);
  });
 
  it('counts a day covered by two certificates once', () => {
    const result = computeSickLeavePay(
      employee,
      [sick('2026-05-01', '2026-05-05'), sick('2026-05-04', '2026-05-06')],
      { from: '2026-05-01', to: '2026-05-31' },
      0,
      null,
    );
    expect(result.daysUsed).toBe(6);
  });
 
  it('opens no window for a period without sickness', () => {
    const result = computeSickLeavePay(employee, [], { from: '2026-05-01', to: '2026-05-31' }, 0, null);
    expect(result.year).toBeNull();
    expect(result.protection).toEqual({
      daysUsed: 0,
      daysRemaining: 120,
      protected: true,
      windowEnds: null,
    });
  });
 
  it('keeps the Article 82 protection until all 120 days are used', () => {
    const year = openSickYear('2026-03-10', 'hijri');
    expect(protectionStatus(year, 119).protected).toBe(true);
    expect(protectionStatus(year, 120).protected).toBe(false);
  });
 
  it('refuses to compute for a civil-service employee', () => {
    expect(() => assertLabourLaw('civil-service')).toThrow(RegimeNotSupportedError);
  });
 
  it('refuses to pay an unverified certificate', () => {
    const cert: Certificate = { ...sick('2026-05-01', '2026-05-05'), verified: false };
    expect(() => assertVerified(cert)).toThrow(UnverifiedCertificateError);
  });
 
  it('does not consume Article 117 balance for an occupational injury', () => {
    const injured: Certificate = { ...sick('2026-06-01', '2026-06-02'), cause: 'occupational-injury' };
    const result = computeSickLeavePay(
      employee,
      [injured],
      { from: '2026-06-01', to: '2026-06-30' },
      0,
      null,
    );
    expect(result.routedToInsurance).toEqual(['2026-06-01', '2026-06-02']);
    expect(result.daysUsed).toBe(0);
    expect(result.total).toBe(0);
  });
 
  it('rounds once per slice, not once per day', () => {
    // A daily rate of 333.33 SAR at three quarters over 60 days.
    const odd: WageComponents = { ...wage, housing: 0, transport: 0, basic: 999_990 };
    const rate = dailyRate(odd, 'actual');            // 33_333 halalas exactly
    const [slice] = priceSlices([{ tier: ARTICLE_117[1], days: 60 }], rate);
    expect(slice.amount).toBe(Math.round(rate * 60 * 0.75));
  });
});

اختبار الفترات المتقطعة هو الذي يستحق تشغيله على بيانات حقيقية. صدِّر سنة من سجلات الغياب لديك، ومرّرها عبر splitAcrossTiers بمؤشّر محمول، وقارن بما دفعه نظامك الحالي. والفجوة، إن وُجدت، تكون في ديسمبر ويناير غالبًا.

حلّ المشكلات

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

التصفية تختلف عن حساب يدوي ببضعة ريالات. التقريب اليومي في الغالب. اضرب عبر الشريحة وقرِّب مرة واحدة، وتأكّد أنك تقسم الأجر الشهري على ثلاثين لا على عدد أيام الشهر التقويمي.

أجر المرض يبدو أقل بنحو ثلاثين بالمئة عبر الجميع. أنت على الأجر الأساسي بدل الأجر الفعلي. راجِع wageBase وتأكّد أن بدلي السكن والنقل يدخلان في fixedMonthlyWage.

موظفو العمولة يتقاضون ناقصًا وأصحاب الرواتب الثابتة لا. العمولة غائبة عن الأجر. أدخِل عمولة فترة المرجع وأيام العمل الفعلية في variable.

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

أيام مرضية تختفي في الشهر الذي تتجدد فيه النافذة. أنت تشغّل نسخة قديمة من نقطة الدخول كانت تُسقط الأيام التي قبل الحدّ. يجب أن تحمل النتيجة مجموعة شرائح لكل نافذة، كلٌّ منها موسوم بـ windowStart.

موظف يظهر باستهلاك يتجاوز 120 يومًا. سببان مرجّحان: أيام إصابة عمل تُسجَّل كمرض، أو أيام إجازة سنوية موصولة تُحتسب مرتين. راجِع التوجيه أولًا — فهو الأكثر شيوعًا.

حدّ الشريحة يتغيّر بحسب وقت تشغيل المهمة. المنطقة الزمنية. كل تاريخ في هذه الوحدة هو منتصف ليل بالتوقيت العالمي؛ وأي new Date(iso) يُحلَّل بالتوقيت المحلي في مكان ما من مسارك سيُزيح أيام الحدود.

التحقق ينجح لتقارير لم يتحقق منها أحد. أحدهم يجعل verified افتراضيًا true. اجعله إلزاميًا بلا قيمة افتراضية، وخزِّن verifiedAt كي يظهر السجل غير الموثّق ناقصًا بوضوح بدل أن يكون متساهلًا بصمت.

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

الخاتمة

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

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