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

محرك حساب الأوفر تايم بلغة TypeScript — المادة 107

ابنِ محرك حساب أجر العمل الإضافي لمسير الرواتب السعودي بلغة TypeScript، تنفيذًا للمواد 98 و106 و107 من نظام العمل — الفرق بين الأجر الفعلي والأساسي، قواسم 240 و180 ساعة، ساعات يوم الراحة والعطلات، دوام رمضان، خيار الإجازة التعويضية الذي أوضحته التعديلات الأخيرة، وسقف 720 ساعة سنويًا.

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

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

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

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

  • Node.js 20 أو أحدث
  • TypeScript 5 أو أحدث (npm install -D typescript vitest)
  • إلمام أساسي بوحدات TypeScript واختبارات الوحدات
  • نسخة من نظام العمل السعودي مفتوحة أمامك، المواد من 98 إلى 108 — سنتبع النص، لا الشائع

لا حاجة إلى أي إطار عمل. المحرك مكتبة TypeScript بسيطة يمكنك وضعها داخل مسار API في Next.js أو مهمة رواتب دورية أو خدمة حضور وانصراف.

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

مكتبة صغيرة باسم saudi-overtime-engine تكشف أربع دوال:

  • hourlyRates(wage, ramadan, policy) — أجر الساعة الفعلي والأساسي، مع التعامل الصريح مع قاسمَي 240 و180 ساعة
  • overtimeHours(day, schedule) — كم ساعة من ساعات اليوم إضافية، بما في ذلك أيام الراحة والعطلات الرسمية حيث تكون كل الساعات إضافية
  • overtimePay(days, wage, schedule) — مبلغ المادة 107 عن فترة كاملة، بالهللات
  • capStatus(hoursThisYear) — موقع الموظف من السقف السنوي البالغ 720 ساعة

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

الخطوة 1: اقرأ النص النظامي قبل كتابة المعادلة

ثلاث مواد تحمل كل شيء، وكل واحدة تضيف قاعدة يجب أن يشفّرها محركك:

المادة 98 تضع المعيار: لا يجوز تشغيل العامل أكثر من 8 ساعات في اليوم أو 48 في الأسبوع. وفي رمضان، للموظفين المسلمين، ينخفض المعيار إلى 6 ساعات في اليوم و36 في الأسبوع. كل ما تجاوز المعيار المنطبق فهو عمل إضافي.

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

المادة 107 تسعّرها. الساعة الإضافية تُدفع بـأجر الساعة مضافًا إليه 50% من الأجر الأساسي. وساعات العمل في يوم الراحة الأسبوعية أو في العطلات الرسمية تُعدّ إضافية بكاملها. كما جعلت التعديلات الأخيرة على نظام العمل صريحًا ما كانت عقود كثيرة تفعله: بموافقة الموظف، يمكن تسوية العمل الإضافي بـأيام إجازة تعويضية بدل الأجر.

الفخ يسكن في الأساسين الأجريّين. يفرّق نظام العمل السعودي بين الأجر الأساسي والأجر الفعلي الذي يضيف البدلات الثابتة — السكن والنقل وكل ما يُدفع بانتظام. النصف الأول من الساعة الإضافية يُسعَّر من الأجر الفعلي؛ أما علاوة الـ50% فتُسعَّر من الأجر الأساسي وحده. وأي محرك يحمل حقل "راتب" واحدًا لا يستطيع تنفيذ المادة 107 تنفيذًا صحيحًا.

الخطوة 2: احفظ النقود بالهللات، لا بالفواصل العائمة أبدًا

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

// money.ts
export type Halalas = number; // always an integer
 
export const fromSAR = (sar: number): Halalas => Math.round(sar * 100);
export const toSAR = (halalas: Halalas): number => halalas / 100;

الخطوة 3: نمذج الأجر كما يقسمه النظام

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

// wage.ts
import type { Halalas } from './money';
 
export interface MonthlyWage {
  /** Basic wage — the contractual base, before any allowance. */
  basic: Halalas;
  /** Fixed, regularly paid allowances: housing, transport, and similar. */
  fixedAllowances: Halalas;
}
 
/** Actual wage: the base plus every fixed allowance (Labour Law, Art. 2). */
export const actualWage = (w: MonthlyWage): Halalas => w.basic + w.fixedAllowances;

قرار واحد ينبغي توثيقه كتابة: أي البدلات "ثابتة". بدل النقل المدفوع كل شهر مكانه fixedAllowances؛ أما المكافأة الاستثنائية فلا. المدققون يطلبون هذه القائمة — احفظها في وثيقة سياساتك، لا في ذاكرة أحدهم.

الخطوة 4: أجر الساعة — وسؤال القاسم

يسعّر النظام العمل الإضافي بالساعة لكنه يذكر الأجور بالشهر، لذا يحتاج كل تنفيذ إلى قاسم. العرف الغالب — وهو ما تطبّقه حاسبة الأوفر تايم عندنا — يقسم الأجر الشهري على 240 (8 ساعات × 30 يومًا). وفي رمضان يتقلص شهر العمل إلى 6 ساعات يوميًا، فيصبح القاسم 180، ما يجعل كل ساعة رمضانية، ومن ثم كل ساعة إضافية رمضانية، أعلى قيمة. وتشتق بعض مسيرات الرواتب الأجر من المعيار الأسبوعي بدلًا من ذلك (48 × 52 / 12 = 208 ساعات شهريًا). كلا الطريقتين ينتج أرقامًا يمكن الدفاع عنها؛ ما لا يمكن الدفاع عنه هو الخلط بينهما. اجعل القاسم قيمة سياسة، وثبّته مرة واحدة، ودع الاختبارات تحرسه.

// rates.ts
import type { MonthlyWage } from './wage';
import { actualWage } from './wage';
 
export interface RatePolicy {
  /** Hours dividing the monthly wage in a normal month. 240 = 8h x 30d. */
  monthlyDivisorHours: number;
  /** Hours dividing the monthly wage in Ramadan. 180 = 6h x 30d. */
  ramadanDivisorHours: number;
}
 
export const defaultRatePolicy: RatePolicy = {
  monthlyDivisorHours: 240,
  ramadanDivisorHours: 180,
};
 
export interface HourlyRates {
  /** Hourly rate from the actual wage, in halalas (may carry fractions). */
  actualHourly: number;
  /** Hourly rate from the basic wage, in halalas (may carry fractions). */
  basicHourly: number;
}
 
export function hourlyRates(
  wage: MonthlyWage,
  ramadan: boolean,
  policy: RatePolicy = defaultRatePolicy,
): HourlyRates {
  const divisor = ramadan ? policy.ramadanDivisorHours : policy.monthlyDivisorHours;
  return {
    actualHourly: actualWage(wage) / divisor,
    basicHourly: wage.basic / divisor,
  };
}

لاحظ أن الأجرين يبقيان هنا هللات بفواصل عائمة. هذا مقصود: هما قيمتان وسيطتان، وMath.round الوحيد ينتظر حتى الخطوة 6.

الخطوة 5: صنّف الساعات — الجزء الذي تخطئه أنظمة الحضور

المادة 107 لا تسعّر فقط الساعات التي تجاوزت المعيار اليومي، بل تنص على أن ساعات العمل في يوم الراحة الأسبوعية وفي العطلات الرسمية إضافيةٌ من الدقيقة الأولى. نظام الحضور الذي يقيس فقط "ما فوق 8 ساعات" يُسقط الحالتين بصمت.

// classify.ts
export type DayKind = 'workday' | 'rest-day' | 'official-holiday';
 
export interface DayRecord {
  /** ISO date, e.g. "2026-08-21". */
  date: string;
  kind: DayKind;
  hoursWorked: number;
  /** True when the employee is Muslim and the date falls in Ramadan. */
  ramadan: boolean;
}
 
export interface SchedulePolicy {
  /** Daily standard outside Ramadan (Art. 98): 8. */
  dailyStandardHours: number;
  /** Daily standard during Ramadan for Muslim employees (Art. 98): 6. */
  ramadanDailyStandardHours: number;
}
 
export const defaultSchedule: SchedulePolicy = {
  dailyStandardHours: 8,
  ramadanDailyStandardHours: 6,
};
 
/** Overtime hours in one day, per Art. 98 and Art. 107(2)(3). */
export function overtimeHours(
  day: DayRecord,
  schedule: SchedulePolicy = defaultSchedule,
): number {
  if (day.kind !== 'workday') {
    // Rest day or official holiday: every hour is overtime.
    return day.hoursWorked;
  }
  const standard = day.ramadan
    ? schedule.ramadanDailyStandardHours
    : schedule.dailyStandardHours;
  return Math.max(0, day.hoursWorked - standard);
}

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

الخطوة 6: معادلة المادة 107 في دالة واحدة

بعد أن جهزنا الأجور والتصنيف، تصبح دالة التسعير صغيرة بما يكفي لقراءتها مقابل النص سطرًا بسطر.

// pay.ts
import type { Halalas } from './money';
import type { MonthlyWage } from './wage';
import { hourlyRates, type RatePolicy } from './rates';
import { overtimeHours, type DayRecord, type SchedulePolicy } from './classify';
 
/** Price of one overtime hour: hourly wage + 50% of basic hourly (Art. 107(1)). */
export function overtimeHourRate(actualHourly: number, basicHourly: number): number {
  return actualHourly + 0.5 * basicHourly;
}
 
/** Article 107 overtime pay for a period, in halalas. One rounding, at the end. */
export function overtimePay(
  days: DayRecord[],
  wage: MonthlyWage,
  schedule?: SchedulePolicy,
  ratePolicy?: RatePolicy,
): Halalas {
  let total = 0;
  for (const day of days) {
    const hours = overtimeHours(day, schedule);
    if (hours === 0) continue;
    const rates = hourlyRates(wage, day.ramadan, ratePolicy);
    total += hours * overtimeHourRate(rates.actualHourly, rates.basicHourly);
  }
  return Math.round(total);
}

لنمرّ على المثال الذي تصل إليه كل منتديات الموارد البشرية السعودية في النهاية. أجر أساسي 4,000 ريال، وبدلات ثابتة 800 ريال، فالأجر الفعلي 4,800 ريال. خارج رمضان يكون أجر الساعة الفعلي 4,800 / 240 = 20 ريالًا، وأجر الساعة الأساسي 4,000 / 240 = 16.67 ريالًا. الساعة الإضافية الواحدة تساوي 20 + 8.33 = 28.33 ريالًا. عشر ساعات إضافية تدفع 283.33 ريالًا — والمحرك يعيد 28,333 هللة. التنفيذات الخاطئة تنتج 300 ريال (علاوة على الأجر الفعلي) أو 250 ريالًا (الساعة كلها من الأساسي). خمسون ريالًا في الشهر، مضروبة في عدد الموظفين، مضروبة في السنوات: هذا حجم الالتزام الذي تحسمه هذه الدالة الواحدة.

شغّل الساعات العشر نفسها في رمضان ودع القاسم يقوم بالعمل: 4,800 / 180 = 26.67 ريالًا للساعة الفعلية، و4,000 / 180 = 22.22 ريالًا للأساسية، و37.78 ريالًا للساعة الإضافية — 377.78 ريالًا في المجموع، من دون حالة خاصة واحدة في شيفرة التسعير.

الخطوة 7: الإجازة التعويضية وضعُ تسوية، لا خصم

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

// settlement.ts
export type OvertimeSettlement =
  | { mode: 'pay' }
  | {
      mode: 'comp-leave';
      /** ISO date the employee agreed in writing. */
      consentDate: string;
      /** Reference to the signed consent document. */
      consentRef: string;
    };

إن قال سجل التسوية comp-leave ولم يكن في نظامك consentRef يمكن إبرازه عند الطلب، فعامله على أنه pay. هذا الافتراض يكلّف مالًا؛ والافتراض الآخر يكلّف قضية.

الخطوة 8: تتبّع سقف الـ720 ساعة قبل أن يتتبّعه المفتش

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

// cap.ts
export const ANNUAL_OVERTIME_CAP_HOURS = 720;
 
export interface CapStatus {
  used: number;
  remaining: number;
  exceeded: boolean;
}
 
export function capStatus(hoursThisYear: number): CapStatus {
  return {
    used: hoursThisYear,
    remaining: Math.max(0, ANNUAL_OVERTIME_CAP_HOURS - hoursThisYear),
    exceeded: hoursThisYear > ANNUAL_OVERTIME_CAP_HOURS,
  };
}

أظهر remaining في لوحة الموارد البشرية عند 600 ساعة، لا عند 719. واشتراط الموافقة فوق السقف فردي وكتابي — انضباط الإثبات نفسه الذي رأيناه في الخطوة 7.

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

كل قاعدة أعلاه تتحول إلى سيناريو. هذه هي السيناريوهات التي تُمسك بالتنفيذات الحقيقية:

// engine.test.ts
import { describe, expect, it } from 'vitest';
import { fromSAR } from './money';
import { overtimePay } from './pay';
import type { DayRecord } from './classify';
 
const wage = { basic: fromSAR(4000), fixedAllowances: fromSAR(800) };
 
const workday = (hoursWorked: number, ramadan = false): DayRecord => ({
  date: '2026-03-02',
  kind: 'workday',
  hoursWorked,
  ramadan,
});
 
describe('Article 107 pricing', () => {
  it('prices the premium from the basic wage, not the actual wage', () => {
    // 2h overtime: 2 x (4800/240 + 0.5 x 4000/240) = 2 x 28.333 SAR
    expect(overtimePay([workday(10)], wage)).toBe(5667);
  });
 
  it('pays nothing at or under the daily standard', () => {
    expect(overtimePay([workday(8)], wage)).toBe(0);
  });
 
  it('treats every rest-day hour as overtime', () => {
    const friday: DayRecord = {
      date: '2026-03-06',
      kind: 'rest-day',
      hoursWorked: 5,
      ramadan: false,
    };
    // 5 x 28.333 = 141.67 SAR
    expect(overtimePay([friday], wage)).toBe(14167);
  });
 
  it('applies the 180-hour divisor and 6-hour standard in Ramadan', () => {
    // 8h worked in Ramadan = 2h overtime at (4800/180 + 0.5 x 4000/180)
    expect(overtimePay([workday(8, true)], wage)).toBe(7556);
  });
 
  it('rounds once at the end, not per day', () => {
    const days = Array.from({ length: 3 }, () => workday(9));
    // 3 x 28.333... rounds to 8500, not 3 x 2833 = 8499
    expect(overtimePay(days, wage)).toBe(8500);
  });
});

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

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

أرقامك تخالف حساب الموظف نفسه. في تسع حالات من عشر حسبَ الساعة الإضافية كلها من الأجر الفعلي (أي 1.5 × أجر الساعة الفعلي). أظهر له التقسيم: النظام يسعّر أصل الساعة من الأجر الفعلي والعلاوةَ وحدها من الأساسي.

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

مجاميع رمضان تبدو مرتفعة. يفترض أن تكون أعلى لكل ساعة: القاسم ينخفض إلى 180 والمعيار اليومي إلى 6، فيرتفع الأجر وعدد الساعات الإضافية معًا. النتيجة الخاطئة هي تسعير إضافي رمضان بالأجر العادي.

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

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

الخلاصة

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

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