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

محرّك الإجازات الخاصة في السعودية بـ TypeScript (المواد 113 و114 و115 و160)

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

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

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

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

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

المتطلبات الأساسية

  • Node.js 22 أو أحدث (الأمثلة تستخدم node --experimental-strip-types بلا خطوة بناء)
  • إلمام بـ TypeScript: الاتحادات المميّزة (discriminated unions) وswitch الشامل
  • فهم عملي لمفاهيم الرواتب السعودية (وعاء الأجر، بنود المسيّر)
  • لا حاجة لقاعدة بيانات؛ المحرّك دوالّ خالصة تعمل على التواريخ

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

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

الوقائع الستّ التي يعالجها:

الواقعةالمادةالاستحقاق
الزواج1135 أيام بأجر كامل
ولادة مولود1133 أيام بأجر كامل، تُطلب خلال 7 أيام من الولادة
الوفاة1135 أيام للزوج أو الأصول أو الفروع، و3 أيام للأخ أو الأخت
الحج114من 10 إلى 15 يومًا، مرّة واحدة طوال الخدمة، بعد سنتين متّصلتين
الامتحان115أيام الامتحان الفعلية، بأجر لسنة غير معادة، وبدون أجر للسنة المعادة
العِدّة1604 أشهر وعشرة أيام للمسلمة، و15 يومًا لغيرها

الخطوة 1: نمذج الواقعة لا الرصيد

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

// src/special-leave/types.ts
export type Religion = 'muslim' | 'non-muslim';
export type Relation = 'spouse' | 'ascendant' | 'descendant' | 'sibling';
export type MonthBasis = 'statutory' | 'calendar';
 
export type Employment = {
  hiredOn: string;
  religion: Religion;
  performedHajjBefore: boolean;
  hajjLeaveUsedInService: boolean;
  enrolmentApproved: boolean;
  // Art. 2: a month is 30 days unless the contract or the work
  // regulations say otherwise. Use 'calendar' only if yours do.
  monthBasis: MonthBasis;
};
 
export type Event =
  | { kind: 'marriage'; on: string; requestedStart?: string }
  | { kind: 'newborn'; on: string; requestedStart?: string }
  | { kind: 'bereavement'; on: string; relation: Relation; requestedStart?: string }
  | { kind: 'iddah'; on: string; pregnant?: boolean; deliveryOn?: string }
  | { kind: 'hajj'; on: string; requestedDays?: number }
  | { kind: 'exam'; on: string; examDays: number; repeatYear: boolean };
 
export type Award = {
  article: string;
  paidDays: number;
  unpaidDays: number;
  startsOn: string | null;
  endsOn: string | null;
  deductsFromAnnual: boolean;
  notes: string[];
  refused?: string;
};

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

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

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

الخطوة 2: حساب تواريخ يصمد أمام نهايات الأشهر

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

// src/special-leave/dates.ts
const MS_DAY = 86_400_000;
 
export const parseDate = (iso: string) => {
  const [y, m, d] = iso.split('-').map(Number);
  return new Date(Date.UTC(y, m - 1, d));
};
 
export const toISO = (d: Date) => d.toISOString().slice(0, 10);
 
export const addDays = (iso: string, n: number) =>
  toISO(new Date(parseDate(iso).getTime() + n * MS_DAY));
 
export const daysBetween = (a: string, b: string) =>
  Math.round((parseDate(b).getTime() - parseDate(a).getTime()) / MS_DAY);
 
export function addMonths(iso: string, n: number): string {
  const d = parseDate(iso);
  const day = d.getUTCDate();
  const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
  const last = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 0)).getUTCDate();
  t.setUTCDate(Math.min(day, last));
  return toISO(t);
}
 
export const overlapDays = (aS: string, aE: string, bS: string, bE: string) => {
  const s = Math.max(parseDate(aS).getTime(), parseDate(bS).getTime());
  const e = Math.min(parseDate(aE).getTime(), parseDate(bE).getTime());
  return e < s ? 0 : Math.round((e - s) / MS_DAY) + 1;
};

دالّة addMonths تقصّ الفائض: إضافة شهر إلى 31 يناير تعطي 28 فبراير، لا 3 مارس. هذا القصّ ليس زخرفة، بل حامل أساسي في الخطوة 5.

الخطوة 3: الثوابت، كلٌّ مسنَد إلى مادّته

// src/special-leave/constants.ts
import type { Relation } from './types';
 
export const BEREAVEMENT_DAYS: Record<Relation, number> = {
  spouse: 5,      // Art. 113
  ascendant: 5,   // parents, grandparents
  descendant: 5,  // children, grandchildren
  sibling: 3,     // brother or sister
};
 
export const MARRIAGE_DAYS = 5;                 // Art. 113
export const NEWBORN_DAYS = 3;                  // Art. 113
export const NEWBORN_CLAIM_WINDOW_DAYS = 7;     // Art. 113 — from the date of birth
 
export const STATUTORY_MONTH_DAYS = 30;         // Art. 2 — unless the contract says otherwise
 
export const IDDAH_MONTHS = 4;                  // Art. 160
export const IDDAH_EXTRA_DAYS = 10;             // Art. 160
export const IDDAH_NON_MUSLIM_DAYS = 15;        // Art. 160
 
export const HAJJ_MIN_DAYS = 10;                // Art. 114
export const HAJJ_MAX_DAYS = 15;                // Art. 114
export const HAJJ_MIN_TENURE_YEARS = 2;         // Art. 114

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

الخطوة 4: المادة 113 — ثلاث إجازات واقعة واليوم الذي يبدأ فيه العدّ

// src/special-leave/engine.ts
const refuse = (article: string, why: string): Award => ({
  article, paidDays: 0, unpaidDays: 0, startsOn: null, endsOn: null,
  deductsFromAnnual: false, notes: [], refused: why,
});
 
const span = (start: string, days: number) => ({ start, end: addDays(start, days - 1) });
 
// Art. 113: marriage and bereavement leave "are all counted from the date of
// the event". The period is fixed on the calendar; a late start shortens it.
const fromEvent = (on: string, days: number, requestedStart?: string): Award => {
  const lastDay = addDays(on, days - 1);
  const start = requestedStart ?? on;
  if (daysBetween(on, start) < 0) return refuse('113', 'Leave cannot start before the event.');
  if (daysBetween(start, lastDay) < 0)
    return refuse('113', `All ${days} days ran from ${on} and ended on ${lastDay}.`);
  const paid = daysBetween(start, lastDay) + 1;
  const notes = [`Counted from the date of the event (${on}); ends ${lastDay}.`];
  if (paid < days) notes.push(`${days - paid} of the ${days} days had already elapsed.`);
  return {
    article: '113', paidDays: paid, unpaidDays: 0,
    startsOn: start, endsOn: lastDay, deductsFromAnnual: false, notes,
  };
};
 
// --- marriage ---
case 'marriage': {
  const award = fromEvent(ev.on, MARRIAGE_DAYS, ev.requestedStart);
  award.notes.unshift('The event is the marriage date, not the date of the contract (aqd qiran).');
  return award;
}
 
// --- newborn ---
case 'newborn': {
  const start = ev.requestedStart ?? ev.on;
  const elapsed = daysBetween(ev.on, start);
  if (elapsed < 0) return refuse('113', 'Leave cannot start before the birth.');
  if (elapsed >= NEWBORN_CLAIM_WINDOW_DAYS)
    return refuse('113', `Claim window of ${NEWBORN_CLAIM_WINDOW_DAYS} days from birth has expired.`);
  const { end } = span(start, NEWBORN_DAYS);
  return {
    article: '113', paidDays: NEWBORN_DAYS, unpaidDays: 0,
    startsOn: start, endsOn: end, deductsFromAnnual: false,
    notes: [`Started on day ${elapsed} of the 7-day window.`],
  };
}
 
// --- bereavement ---
case 'bereavement': {
  const days = BEREAVEMENT_DAYS[ev.relation];
  const award = fromEvent(ev.on, days, ev.requestedStart);
  award.notes.unshift(`Relation '${ev.relation}' carries ${days} days.`);
  return award;
}

في هذه الكتلة ثلاثة فخاخ.

الزواج والوفاة يُحتسبان من الواقعة لا من الطلب. تنص المادة 113 بعد تعديلها بالمرسوم الملكي م/44 على أن هذه الإجازات «تحتسب جميعها من تاريخ الواقعة». فالمدة مثبّتة على التقويم من يوم الوفاة أو الزواج، والعامل الذي يبدأ متأخرًا لا يؤخّر تاريخ النهاية. ومن بلغه خبر وفاة أحد والديه بعد يومين من حدوثها بقي له ثلاثة أيام من الخمسة، لا خمسة. لذلك تثبّت fromEvent اليوم الأخير أولًا وتشتقّ منه الأيام المدفوعة، ولذلك يكون البدء بعد اليوم الأخير رفضًا مع سببه. وكانت النسخة السابقة من هذا المحرّك تسمح لـ requestedStart بتحريك إجازة الزواج كلها، فتمنح بهدوء أيامًا لا تمنحها المادة.

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

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

الخطوة 5: المادة 160 — العِدّة، والشهر الذي تعرّفه المادة الثانية

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

السؤال هو معنى «الشهر»، ونظام العمل يجيب عنه قبل المادة 160. فالمادة الثانية تعرّف الشهر بأنه ثلاثون يومًا ما لم ينص على خلاف ذلك في عقد العمل أو في لائحة تنظيم العمل. فالمدة النظامية 4 × 30 + 10 = 130 يومًا أيًّا كان تاريخ الوفاة. ولا يُطبَّق الحساب التقويمي إلا إذا عرّف العقد أو اللائحة الأشهر بالأشهر التقويمية.

case 'iddah': {
  if (emp.religion === 'non-muslim') {
    const { start, end } = span(ev.on, IDDAH_NON_MUSLIM_DAYS);
    return {
      article: '160', paidDays: IDDAH_NON_MUSLIM_DAYS, unpaidDays: 0,
      startsOn: start, endsOn: end, deductsFromAnnual: false,
      notes: ['Non-Muslim widow: 15 days at full pay.'],
    };
  }
 
  // Art. 2 fixes the month at 30 days unless the contract says otherwise.
  const full = emp.monthBasis === 'calendar'
    ? daysBetween(ev.on, addDays(addMonths(ev.on, IDDAH_MONTHS), IDDAH_EXTRA_DAYS))
    : IDDAH_MONTHS * STATUTORY_MONTH_DAYS + IDDAH_EXTRA_DAYS;
  const notes = [`Four months and ten days on a ${emp.monthBasis} month basis = ${full} days.`];
 
  if (ev.pregnant && ev.deliveryOn && daysBetween(ev.on, ev.deliveryOn) >= 0) {
    const toDelivery = daysBetween(ev.on, ev.deliveryOn) + 1; // delivery day included
    if (toDelivery < full) {
      notes.push('Delivered inside the iddah: the remainder lapses at delivery.');
      return {
        article: '160', paidDays: toDelivery, unpaidDays: 0,
        startsOn: ev.on, endsOn: ev.deliveryOn, deductsFromAnnual: false, notes,
      };
    }
    if (toDelivery > full) {
      const unpaid = toDelivery - full;
      notes.push(`Pregnant: unpaid extension of ${unpaid} days to delivery.`);
      return {
        article: '160', paidDays: full, unpaidDays: unpaid,
        startsOn: ev.on, endsOn: ev.deliveryOn, deductsFromAnnual: false, notes,
      };
    }
  }
 
  return {
    article: '160', paidDays: full, unpaidDays: 0,
    startsOn: ev.on, endsOn: addDays(ev.on, full - 1), deductsFromAnnual: false, notes,
  };
}

شغّلها من تاريخين على كل أساس:

statutory month (Art. 2), any date   = 130 days
calendar months, from 2026-02-01     = 130 days
calendar months, from 2026-10-01     = 133 days

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

تصحيح (18 سبتمبر 2026). كانت نسخة سابقة من هذه الخطوة تقول إن العِدّة «ليست 130 يومًا» وإن تخزين 130 يُنقص الأجر. وقد أغفل ذلك المادة الثانية. فبحسب نص النظام، 130 يومًا هي الأصل، ولا يُطبَّق الحساب التقويمي إلا بنصّ في العقد.

الوضع يُنهي العِدّة. إن وضعت الأرملة الحامل قبل انقضاء الأيام الـ130 سقط الباقي؛ فيدفع المحرّك حتى يوم الوضع شاملًا إيّاه ويذكر ذلك في الملاحظات، ثم تبدأ إجازة الوضع وفق المادة 151. وإن وضعت بعد انقضاء المدة، فالفرق هو التمديد دون أجر.

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

انتبه إلى صيغة الحدّ الأدنى. النصّ يقول «لا تقلّ عن» أربعة أشهر وعشرة أيام. فالقيمة المحسوبة حدّ أدنى لا سقف. وسياسة صاحب عمل تمنح أكثر منها نظامية.

الخطوة 6: المادة 114 — إجازة الحج يجب ألّا تدفع العيد مرّتين

إجازة الحج تحمل أكثر الشروط المسبقة بين إجازات النظام، وفخًّا حسابيًا واحدًا يكلّف مالًا حقيقيًا.

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

case 'hajj': {
  const tenureDays = daysBetween(emp.hiredOn, ev.on);
  if (tenureDays < HAJJ_MIN_TENURE_YEARS * 365)
    return refuse('114', 'Requires two continuous years with the same employer.');
  if (emp.performedHajjBefore)
    return refuse('114', 'Worker has already performed Hajj.');
  if (emp.hajjLeaveUsedInService)
    return refuse('114', 'Hajj leave is once for the whole service.');
 
  const granted = Math.min(
    HAJJ_MAX_DAYS,
    Math.max(HAJJ_MIN_DAYS, ev.requestedDays ?? HAJJ_MIN_DAYS),
  );
  const { start, end } = span(ev.on, granted);
 
  const eid = holidays
    .filter((h) => h.name === 'eid-al-adha')
    .reduce((n, h) => n + overlapDays(start, end, h.start, addDays(h.start, h.days - 1)), 0);
 
  const notes = [`Granted ${granted} days, of which ${eid} fall inside the Eid al-Adha holiday.`];
  if (eid) notes.push('Eid days are already paid as a public holiday and are not paid twice.');
 
  return {
    article: '114', paidDays: granted - eid, unpaidDays: 0,
    startsOn: start, endsOn: end, deductsFromAnnual: false, notes,
  };
}

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

تواريخ العيد بيانات لا معادلة. عيد الأضحى يُحدَّد برؤية الهلال ويُعلَن كلّ سنة. لا تحسبه، بل احقنه:

export type Holiday = { name: string; start: string; days: number };
 
// Replace with the officially announced dates each year — these are astronomical
// estimates and the announced dates routinely differ by a day.
export const HOLIDAYS_2026: Holiday[] = [
  { name: 'eid-al-adha', start: '2026-05-27', days: 4 },
];

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

الخطوة 7: المادة 115 — إجازة الامتحان التي تنقلب بلا أجر

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

case 'exam': {
  if (!emp.enrolmentApproved)
    return refuse('115', 'Employer never approved the enrolment, so no entitlement arises.');
 
  const { start, end } = span(ev.on, ev.examDays);
 
  return ev.repeatYear
    ? {
        article: '115', paidDays: 0, unpaidDays: ev.examDays,
        startsOn: start, endsOn: end, deductsFromAnnual: false,
        notes: ['Repeated year: leave is owed, but unpaid.'],
      }
    : {
        article: '115', paidDays: ev.examDays, unpaidDays: 0,
        startsOn: start, endsOn: end, deductsFromAnnual: false,
        notes: ['Non-repeated year: actual exam days at full pay.'],
      };
}

و«أيام الامتحان الفعلية» تُؤخذ حرفيًا. ليست طول فترة الامتحانات، بل الأيام التي يجلس فيها العامل للاختبار. فدورة امتحانات مدّتها ثلاثة أسابيع بأربع مواد هي أربعة أيام، لا واحد وعشرون.

الخطوة 8: التجميع وإظهار سبب الرفض

اربط الفروع في دالّة واحدة شاملة:

export function awardSpecialLeave(
  emp: Employment,
  ev: Event,
  holidays: Holiday[] = [],
): Award {
  switch (ev.kind) {
    // ... the six cases from Steps 4 to 7
  }
}

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

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

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

واحد وعشرون تأكيدًا تغطّي كلّ فرع وكلّ فخّ. هذه المجموعة تعمل بنجاح:

import assert from 'node:assert/strict';
import { awardSpecialLeave, addMonths, type Employment } from './engine.ts';
 
const emp: Employment = {
  hiredOn: '2022-01-10',
  religion: 'muslim',
  performedHajjBefore: false,
  hajjLeaveUsedInService: false,
  enrolmentApproved: true,
  monthBasis: 'statutory',
};
 
// Art. 113 — marriage never touches the annual balance
const marriage = awardSpecialLeave(emp, { kind: 'marriage', on: '2026-03-01' });
assert.equal(marriage.paidDays, 5);
assert.equal(marriage.endsOn, '2026-03-05');
assert.equal(marriage.deductsFromAnnual, false);
 
// Art. 113 — counted from the event: a late start does not move the end
const lateMarriage = awardSpecialLeave(emp,
  { kind: 'marriage', on: '2026-03-01', requestedStart: '2026-03-04' });
assert.equal(lateMarriage.paidDays, 2);
assert.equal(lateMarriage.endsOn, '2026-03-05');
 
// Art. 113 — news of a death that arrives two days late leaves three of five days
const lateNews = awardSpecialLeave(emp,
  { kind: 'bereavement', on: '2026-03-01', relation: 'ascendant', requestedStart: '2026-03-03' });
assert.equal(lateNews.paidDays, 3);
assert.equal(lateNews.endsOn, '2026-03-05');
 
// Art. 113 — the newborn window closes on day 7
const late = awardSpecialLeave(emp, {
  kind: 'newborn', on: '2026-03-01', requestedStart: '2026-03-08',
});
assert.equal(late.paidDays, 0);
assert.match(late.refused!, /window/);
 
// Art. 113 — five days for an ascendant, three for a sibling
assert.equal(awardSpecialLeave(emp,
  { kind: 'bereavement', on: '2026-03-01', relation: 'ascendant' }).paidDays, 5);
assert.equal(awardSpecialLeave(emp,
  { kind: 'bereavement', on: '2026-03-01', relation: 'sibling' }).paidDays, 3);
 
// Art. 2 + 160 — a statutory month is 30 days, so 130 whatever the date
const feb = awardSpecialLeave(emp, { kind: 'iddah', on: '2026-02-01' });
const oct = awardSpecialLeave(emp, { kind: 'iddah', on: '2026-10-01' });
assert.equal(feb.paidDays, 130);
assert.equal(oct.paidDays, 130);
assert.equal(feb.endsOn, '2026-06-10');
 
// ...unless the contract defines the month as a calendar month
const cal: Employment = { ...emp, monthBasis: 'calendar' };
assert.equal(awardSpecialLeave(cal, { kind: 'iddah', on: '2026-10-01' }).paidDays, 133);
 
// Art. 160 — a delivery inside the iddah ends it
const delivered = awardSpecialLeave(emp,
  { kind: 'iddah', on: '2026-02-01', pregnant: true, deliveryOn: '2026-03-02' });
assert.equal(delivered.paidDays, 30);
assert.equal(delivered.unpaidDays, 0);
 
// Art. 114 — fifteen granted days over a four-day Eid cost eleven
const hajj = awardSpecialLeave(emp,
  { kind: 'hajj', on: '2026-05-25', requestedDays: 15 },
  [{ name: 'eid-al-adha', start: '2026-05-27', days: 4 }]);
assert.equal(hajj.paidDays, 11);
 
// Art. 115 — a repeated year is owed, but unpaid
const repeat = awardSpecialLeave(emp,
  { kind: 'exam', on: '2026-06-01', examDays: 4, repeatYear: true });
assert.equal(repeat.paidDays, 0);
assert.equal(repeat.unpaidDays, 4);
 
// month-end clamp
assert.equal(addMonths('2026-01-31', 1), '2026-02-28');

شغّلها بـ node --experimental-strip-types test.ts. والتأكيدات الجديرة بالبقاء إلى الأبد هي زوج البدء المتأخر في المادة 113، وأساس الشهر في العِدّة، وتقاطع الحج مع العيد — فهي أول ما تكسره إعادة هيكلة حسنة النيّة.

حلّ المشكلات

تاريخ نهاية العِدّة يرجع مبكّرًا بيوم. بنيت تاريخ النهاية بالتوقيت المحلّي بدل UTC، فأرجع جهاز غربيّ عن الرياض الحدّ يومًا للوراء. كلّ تاريخ في هذا المحرّك يجب أن يمرّ عبر parseDate.

إجازة الحج تُرفض لموظّف طويل الخدمة. فحص المدّة يستعمل 2 * 365 يومًا ويتجاهل السنوات الكبيسة. وهذا تحفّظ مقصود: يرفض حالة حدّية بدل أن يمنح حالة تسقط في التدقيق. وإن كانت سياستك عدّ السنوات التقويمية، فاستبدل الفحص بـ daysBetween(emp.hiredOn, addMonths(ev.on, -24)) >= 0.

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

نوع إجازة يُرجع undefined. أضفت متغيّرًا إلى Event ولم تضف له case. فعّل noImplicitReturns وسيلتقطه المصرّف قبل أن تلتقطه دورة الرواتب.

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

الخلاصة

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

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

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