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

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

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

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

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

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

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

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

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

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

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

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

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

الخطوة 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 إن للعامل الحق في إجازة مرضية «بأجر» عن الثلاثين يومًا الأولى، و«بثلاثة أرباع الأجر» عن الستين التالية. لكنها لا توضّح أيّ أجر.

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

/** 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 allowances paid regularly and unconditionally, per month. */
  otherRegular: Halalas;
  /** Variable pay: commission, bonuses, irregular incentives. Excluded. */
  variable: Halalas;
};
 
export type WageBase = 'actual' | 'basic';
 
export function monthlyWage(w: WageComponents, base: WageBase): Halalas {
  if (base === 'basic') return w.basic;
  // The actual wage: basic plus what is paid regularly and unconditionally.
  // Variable pay is deliberately excluded — it is not part of the fixed wage.
  return w.basic + w.housing + w.transport + w.otherRegular;
}
 
/**
 * The daily rate. The Labour Law prices a month at 30 days for wage
 * purposes, so this divisor is 30 regardless of how many days the
 * calendar month actually has. Kept fractional on purpose — see Step 4.
 */
export function dailyRate(w: WageComponents, base: WageBase): number {
  return monthlyWage(w, base) / 30;
}

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

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

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

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

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

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

export type SickYear = {
  /** Inclusive ISO date on which this window opened. */
  start: string;
  /** Inclusive ISO date on which it closes: start plus one year, minus one day. */
  end: string;
};
 
const DAY_MS = 86_400_000;
 
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);
}
 
export function openSickYear(firstSickDay: string): SickYear {
  const [y, m, d] = firstSickDay.split('-').map(Number);
  // One year later, minus one day, so the window is inclusive on both ends.
  const endMs = Date.UTC(y + 1, m - 1, d) - DAY_MS;
  return { start: firstSickDay, end: toISO(endMs) };
}
 
export function isWithin(year: SickYear, day: string): boolean {
  const t = toUTC(day);
  return t >= toUTC(year.start) && t <= toUTC(year.end);
}

تفصيلان يستحقان التثبيت. اعمل بمنتصف ليل التوقيت العالمي في كل مكان — فمحرك رواتب يحترم المنطقة الزمنية المحلية سيُزيح يوم الحدّ بصمت حين يُنقل الخادم، ويوم مرضي يهبط قبل يوم واحد قد يدفع فترة كاملة إلى شريحة أخرى. وابنِ النافذة بحساب تقويمي (Date.UTC(y + 1, ...)) لا بإضافة 365 يومًا، كي لا تقتطع السنوات الكبيسة يومًا من استحقاق أحدهم.

الخطوة 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: أظهِر حارس الفصل

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

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

export type Protection = {
  daysUsed: number;
  daysRemaining: number;
  /** True while the statutory sick-leave entitlement is not yet exhausted. */
  protected: boolean;
  windowEnds: string;
};
 
const ENTITLEMENT_DAYS = 120;
 
export function protectionStatus(year: SickYear, daysUsed: number): Protection {
  const daysRemaining = Math.max(0, ENTITLEMENT_DAYS - daysUsed);
  return {
    daysUsed,
    daysRemaining,
    protected: daysRemaining > 0,
    windowEnds: year.end,
  };
}

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

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

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

export type Employee = {
  id: string;
  regime: Regime;
  wage: WageComponents;
  wageBase: WageBase;
};
 
export type SickLeaveResult = {
  employeeId: string;
  period: { from: string; to: string };
  year: SickYear;
  slices: PaySlice[];
  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 illnessDays: string[] = [];
  const injuryDays: 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);
    if (routed.route === 'occupational-hazards') injuryDays.push(...routed.days);
    else illnessDays.push(...routed.days);
  }
 
  illnessDays.sort();
 
  // The window opens on the first sick day if none is open, and rolls
  // forward the moment a sick day falls outside the current one.
  let year = openWindow;
  let used = priorDaysUsed;
  if (year === null && illnessDays.length > 0) {
    year = openSickYear(illnessDays[0]);
    used = 0;
  }
  if (year !== null) {
    for (const day of illnessDays) {
      if (!isWithin(year, day)) {
        year = openSickYear(day);
        used = 0;
        break;
      }
    }
  }
  if (year === null) year = openSickYear(period.from);
 
  const inWindow = illnessDays.filter((d) => isWithin(year!, d));
  const { slices, beyondEntitlement } = splitAcrossTiers(used, inWindow.length);
  const priced = priceSlices(slices, dailyRate(employee.wage, employee.wageBase));
 
  return {
    employeeId: employee.id,
    period,
    year,
    slices: priced,
    total: priced.reduce((sum, s) => sum + s.amount, 0),
    beyondEntitlement,
    routedToInsurance: injuryDays,
    protection: protectionStatus(year, used + inWindow.length),
  };
}
 
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: 300_000,   // commission — must be excluded
};
 
describe('Article 117 sick leave', () => {
  it('excludes variable pay from the actual wage', () => {
    expect(monthlyWage(wage, 'actual')).toBe(1_050_000);
    expect(monthlyWage(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('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 the window one year after the first sick day, inclusive', () => {
    expect(openSickYear('2026-03-10')).toEqual({
      start: '2026-03-10',
      end: '2027-03-09',
    });
  });
 
  it('handles a leap year without losing a day', () => {
    expect(openSickYear('2027-03-01').end).toBe('2028-02-29');
  });
 
  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 = {
      code: 'GSL-000',
      from: '2026-05-01',
      to: '2026-05-05',
      cause: 'illness',
      verified: false,
    };
    expect(() => assertVerified(cert)).toThrow(UnverifiedCertificateError);
  });
 
  it('does not consume Article 117 balance for an occupational injury', () => {
    const routed = routeByCause('occupational-injury', ['2026-06-01', '2026-06-02']);
    expect(routed.route).toBe('occupational-hazards');
  });
 
  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 وتأكّد أن بدلي السكن والنقل يدخلان في monthlyWage.

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

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

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

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

الخاتمة

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

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