الكتابات/tutorial/2026/11
● Tutorial6 نوفمبر 2026·22 دقيقة

الإفصاح عن بيانات التدريب في قوى بـ TypeScript قبل 31 يناير

ابنِ أداة صغيرة بلغة TypeScript تحوّل سجلات الموارد البشرية والتدريب إلى الأرقام التي يجب على المنشآت السعودية التي يعمل لديها 50 عاملًا فأكثر الإفصاح عنها في منصة قوى قبل 31 يناير 2027: عدد العاملين في نهاية السنة، والمتدربون الذين أكملوا التدريب في الفئات الأربع، والساعات والتكاليف، وشرط الوحدات الثماني، ونسبة 2% للخريجين، والإفصاح بعدم التنفيذ، والغرامة المترتبة.

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

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

سنبني أداة تفعل ذلك بالضبط، ولا شيء غيره:

  1. تحدد هل ينطبق الالتزام، بناءً على عدد العاملين في 31 ديسمبر.
  2. ترفض سجلات التدريب الخاطئة قبل أن تدخل في أي مجموع.
  3. تستخرج أرقام كل فئة كما يطلبها القرار: المتدربون الذين أكملوا، والساعات، والتكلفة.
  4. تنبّه إلى المتدربين الذين لم يبلغوا حد الوحدات الثماني.
  5. تتحقق من نسبة 2% للخريجين والباحثين عن عمل.
  6. تعرض الغرامة المترتبة إذا لم يُقدَّم شيء.

ماذا تقول النصوص فعلًا

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

القاعدةالمصدرفي الشيفرة
المنشآت التي يعمل بها 50 عاملًا فأكثر "في نهاية العام" ملزمة بالإفصاح سنويًا عبر قوىالقرار الوزاري المعلن في 19 أغسطس 2023isCovered، العدد في 31 ديسمبر
الإفصاح عن ساعات التدريب وأعداد المتدربين الذين أكملوا التدريب من أربع فئات: العاملين، والطلاب، والخريجين، والباحثين عن عملالإعلان نفسهbuildDisclosure يحتسب الدورات المكتملة فقط
ألا تقل مدة التدريب المفصح عنه عن ثماني وحدات لكل متدرب سنويًاالإعلان نفسهminUnitsPerTrainee: 8، وhoursPerUnit إعداد
المنشأة التي نفّذت تفصح عن المتدربين والبرامج والساعات والتكاليف، والتي لم تنفّذ تفصح عن عدم التنفيذإعلان الوزارة، 2 نوفمبر 2026nil: true
تدريب الخريجين والباحثين عن عمل بنسبة 2% من إجمالي العاملينالإعلان نفسهgraduateCheck
عدم الإفصاح: 5,000 ريال، للفئة (أ) فقط (50 عاملًا فأكثر)القرار الوزاري 112377، البند 50nonFilingExposure
أرقام أقدم: 5,000 و10,000 و15,000 ريال حسب الحجم، وتتضاعف عند التكرارإعلان الوزارة، 10 نوفمبر 2025تُعاد كقراءة ثانية

أمران لا تحددهما النصوص، وعليك أن تحسمهما قبل الإفصاح:

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

خارج نطاق هذا الدرس: البند 51 من القرار 112377 (500 ريال عن كل عامل سعودي لم يُدرَّب وفق النسب والآليات التي يحددها الوزير). هذه النسب ليست في النصوص التي قرأناها، فلا تدّعي الأداة حسابها.

المتطلبات

  • Node.js 20 أو أحدث
  • معرفة أساسية بـ TypeScript
  • ملف تصدير لسجل العاملين (معرّف العامل، الرقم الموحد، تاريخ البداية والنهاية)
  • سجلات التدريب لعام 2026 من أي مكان توجد فيه: منصة التعلّم، أو دفتر المالية للدورات الخارجية، أو كشوف الحضور للتدريب على رأس العمل

الخطوة 1: إعداد المشروع

mkdir qiwa-training && cd qiwa-training
npm init -y
npm pkg set type=module
npm install -D typescript tsx @types/node
mkdir src test

أضف ملف tsconfig.json مع تفعيل strict. كل ملف أدناه يستورد بامتداد .ts، وهو ما يشغّله tsx مباشرة.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true
  },
  "include": ["src", "test"]
}

الخطوة 2: نمذجة البيانات

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

// The four trainee groups named in the 2023 ministerial decision.
export type TraineeCategory = "employee" | "student" | "graduate" | "jobSeeker";
 
export interface Worker {
  workerId: string;
  unifiedNumber: string; // the establishment's unified number (700...)
  startDate: string; // ISO date, first day on the establishment's register
  endDate?: string; // ISO date, last day; absent while still employed
}
 
export interface TrainingRecord {
  traineeId: string; // iqama / national ID, or an internal ID for non-employees
  category: TraineeCategory;
  programmeId: string;
  startDate: string; // ISO date
  endDate: string; // ISO date
  hours: number; // contact hours actually delivered
  costSar: number; // what the establishment paid, in riyals
  completed: boolean;
}
 
export interface Programme {
  programmeId: string;
  title: string;
  provider: string;
  internal: boolean; // on-the-job or in-house, versus an external provider
}
 
// Settings the Ministry has not published in a form we could read.
// Keep them explicit so a reviewer can see every assumption.
export interface Policy {
  year: number; // the training year being disclosed
  hoursPerUnit: number; // how many hours count as one "training unit"
  minUnitsPerTrainee: number; // 8 in the 2023 decision
  graduateRate: number; // 0.02 per the 2 November 2026 notice
}

بعض قرارات النمذجة التي تستحق الذكر:

  • hours هي الساعات المنفّذة لا المجدولة. إن ترك أحدهم دورة من خمسة أيام في يومها الثاني، فسجّل يومين.
  • costSar هو ما دفعته المنشأة. التدريب الداخلي بلا فاتورة قيمته 0 لا فراغ. التكلفة الفارغة مشكلة في البيانات، والتكلفة الصفرية حقيقة.
  • الطلاب والخريجون والباحثون عن عمل ليسوا على مسيّر الرواتب، لذلك traineeId نص حر. استخدم رقم الهوية الوطنية أو الإقامة متى توفر، حتى لا يُحتسب الشخص مرتين.

الخطوة 3: هل ينطبق الالتزام؟

ينطبق قرار 2023 على المنشآت التي يعمل بها 50 عاملًا فأكثر "في نهاية العام الحالي". نقرأ ذلك على أنه السجل في 31 ديسمبر من سنة التدريب. والقرار 112377 يحتسب العاملين تحت الرقم الموحد للمنشأة، لذلك يجري العدّ لكل رقم موحد، لا لكل سجل تجاري.

import type { Worker } from "./types.ts";
 
export const COVERAGE_THRESHOLD = 50;
 
// A worker counts if they were on the register on the given day.
function onRegister(worker: Worker, day: string): boolean {
  if (worker.startDate > day) return false;
  if (worker.endDate !== undefined && worker.endDate < day) return false;
  return true;
}
 
// Head count for one unified number on one day, each worker counted once
// even if they appear twice (for example after a transfer between branches).
export function headcountOn(
  workers: Worker[],
  unifiedNumber: string,
  day: string,
): number {
  const ids = new Set<string>();
  for (const w of workers) {
    if (w.unifiedNumber === unifiedNumber && onRegister(w, day)) {
      ids.add(w.workerId);
    }
  }
  return ids.size;
}
 
// The 2023 decision tests the head count "at the end of the year".
// We read that as 31 December of the training year.
export function isCovered(
  workers: Worker[],
  unifiedNumber: string,
  year: number,
): { covered: boolean; headcount: number; asOf: string } {
  const asOf = `${year}-12-31`;
  const headcount = headcountOn(workers, unifiedNumber, asOf);
  return { covered: headcount >= COVERAGE_THRESHOLD, headcount, asOf };
}

تعالج الدالة فخّين. العامل الذي يظهر مرتين في ملف التصدير، وهو ما يحدث بعد النقل بين الفروع، يُحتسب مرة واحدة. والعامل الذي انتهى عقده في 30 ديسمبر لا يُحتسب في 31 ديسمبر.

إن كان عددكم قريبًا من 50، فطابق العدد مع ما يظهر في قوى قبل الاعتماد عليه. سجل الوزارة هو الذي سيُراجَع، لا سجلك.

الخطوة 4: التحقق قبل الجمع

أغلب الإفصاحات الخاطئة ليست خطأ في الحساب. إنها دورة من يناير 2027 احتُسبت في 2026، أو سطر أدخله مديران مرتين، أو رمز برنامج لا يعرف أحد ما هو. افحص السطور أولًا، وأبلغ عن كل مشكلة بدل حذف السطر الخاطئ بصمت.

import type { Programme, TrainingRecord } from "./types.ts";
 
export interface Problem {
  index: number; // row in the input, starting at 0
  code:
    | "OUTSIDE_YEAR"
    | "BAD_DATES"
    | "BAD_HOURS"
    | "BAD_COST"
    | "UNKNOWN_PROGRAMME"
    | "DUPLICATE";
  message: string;
}
 
// Row-level checks to run before anything is typed into Qiwa.
// A row with a problem is reported, never silently dropped.
export function validateRecords(
  records: TrainingRecord[],
  programmes: Programme[],
  year: number,
): Problem[] {
  const problems: Problem[] = [];
  const known = new Set(programmes.map((p) => p.programmeId));
  const seen = new Map<string, number>();
  const first = `${year}-01-01`;
  const last = `${year}-12-31`;
 
  records.forEach((r, index) => {
    if (r.endDate < r.startDate) {
      problems.push({ index, code: "BAD_DATES", message: "ends before it starts" });
    }
    if (r.endDate < first || r.endDate > last) {
      problems.push({
        index,
        code: "OUTSIDE_YEAR",
        message: `ended ${r.endDate}, outside ${year}`,
      });
    }
    if (!Number.isFinite(r.hours) || r.hours <= 0) {
      problems.push({ index, code: "BAD_HOURS", message: "hours must be above zero" });
    }
    if (!Number.isFinite(r.costSar) || r.costSar < 0) {
      problems.push({ index, code: "BAD_COST", message: "cost cannot be negative" });
    }
    if (!known.has(r.programmeId)) {
      problems.push({
        index,
        code: "UNKNOWN_PROGRAMME",
        message: `programme ${r.programmeId} is not in the catalogue`,
      });
    }
    const key = `${r.traineeId}|${r.programmeId}|${r.startDate}`;
    const prior = seen.get(key);
    if (prior !== undefined) {
      problems.push({
        index,
        code: "DUPLICATE",
        message: `same trainee, programme and start date as row ${prior}`,
      });
    } else {
      seen.set(key, index);
    }
  });
 
  return problems;
}

تنتمي الدورة إلى السنة التي انتهت فيها. هذا اصطلاحنا، واخترناه لأن القرار يحتسب المتدربين الذين أكملوا التدريب. دورة تمتد من 20 ديسمبر 2026 إلى 2 يناير 2027 تنتمي إلى إفصاح 2027، وينبّه المدقق إليها إن ظهرت في 2026.

الخطوة 5: استخراج أرقام الإفصاح

الآن المجاميع. لا تُحتسب إلا الدورات المكتملة. ويُحتسب كل شخص مرة واحدة في فئته مهما أكمل من دورات، بينما تُجمع الساعات والتكاليف عبر الدورات كلها.

import type { Policy, TraineeCategory, TrainingRecord } from "./types.ts";
 
const CATEGORIES: TraineeCategory[] = ["employee", "student", "graduate", "jobSeeker"];
 
export interface CategoryTotals {
  completedTrainees: number; // distinct people with at least one completed course
  hours: number;
  costSar: number;
}
 
export interface Disclosure {
  year: number;
  nil: boolean; // true when there is nothing to report: file the nil declaration
  programmes: number;
  byCategory: Record<TraineeCategory, CategoryTotals>;
  totalHours: number;
  totalCostSar: number;
  belowMinimum: { traineeId: string; units: number }[];
}
 
function emptyTotals(): CategoryTotals {
  return { completedTrainees: 0, hours: 0, costSar: 0 };
}
 
// Rounds to halalas so floating point never shows up in a filing.
function sar(n: number): number {
  return Math.round(n * 100) / 100;
}
 
// Builds the year's figures from records that already passed validation.
// Only completed courses count: the decision asks for trainees
// "who completed training".
export function buildDisclosure(records: TrainingRecord[], policy: Policy): Disclosure {
  const done = records.filter((r) => r.completed);
 
  const byCategory = {} as Record<TraineeCategory, CategoryTotals>;
  for (const c of CATEGORIES) byCategory[c] = emptyTotals();
 
  const people = new Map<TraineeCategory, Set<string>>();
  const hoursPerTrainee = new Map<string, number>();
  const programmes = new Set<string>();
 
  for (const r of done) {
    const t = byCategory[r.category];
    t.hours += r.hours;
    t.costSar = sar(t.costSar + r.costSar);
    if (!people.has(r.category)) people.set(r.category, new Set());
    people.get(r.category)!.add(r.traineeId);
    hoursPerTrainee.set(r.traineeId, (hoursPerTrainee.get(r.traineeId) ?? 0) + r.hours);
    programmes.add(r.programmeId);
  }
  for (const c of CATEGORIES) byCategory[c].completedTrainees = people.get(c)?.size ?? 0;
 
  const belowMinimum: { traineeId: string; units: number }[] = [];
  for (const [traineeId, hours] of hoursPerTrainee) {
    const units = hours / policy.hoursPerUnit;
    if (units < policy.minUnitsPerTrainee) {
      belowMinimum.push({ traineeId, units: Math.round(units * 100) / 100 });
    }
  }
  belowMinimum.sort((a, b) => a.traineeId.localeCompare(b.traineeId));
 
  const totalHours = CATEGORIES.reduce((s, c) => s + byCategory[c].hours, 0);
  const totalCostSar = sar(CATEGORIES.reduce((s, c) => s + byCategory[c].costSar, 0));
 
  return {
    year: policy.year,
    nil: done.length === 0,
    programmes: programmes.size,
    byCategory,
    totalHours,
    totalCostSar,
    belowMinimum,
  };
}

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

غيّر hoursPerUnit لترى لماذا يهم هذا الإعداد. إن كانت الوحدة ساعة، ينجح متدرب حضر تسع ساعات. وإن كانت الوحدة أربع ساعات (نصف يوم)، فلدى المتدرب نفسه 2.25 وحدة فقط فيرسب. إلى أن تقرأ الدليل الإجرائي، اعرض النتيجتين على من سيوقّع الإفصاح.

إن لم تكتمل أي دورة، تكون nil صحيحة. هذه هي النتيجة التي تريد الوزارة أكثر ما تريد أن يُفصح عنها، وهي التي تتخطاها المنشآت أكثر من غيرها: إعلان نوفمبر صريح في أن المنشأة التي لم تنفّذ أي تدريب ملزمة بالإفصاح عن ذلك.

الخطوة 6: الخريجون والغرامة المترتبة

رقمان أخيران. الأول هو نسبة 2% للخريجين والباحثين عن عمل التي يشير إليها إعلان نوفمبر. والثاني هو كلفة عدم الإفصاح.

import type { Disclosure } from "./aggregate.ts";
import type { Policy } from "./types.ts";
 
export interface GraduateCheck {
  required: number; // people, rounded up: our conservative reading
  trained: number; // graduates plus job seekers who completed training
  gap: number;
}
 
// The 2 November 2026 notice cites a decision to train graduates and
// job seekers at 2% of total workers. It does not say how to round, so
// we round up: a firm of 120 needs 3, not 2.4.
export function graduateCheck(
  d: Disclosure,
  headcount: number,
  policy: Policy,
): GraduateCheck {
  const required = Math.ceil(headcount * policy.graduateRate);
  const trained =
    d.byCategory.graduate.completedTrainees + d.byCategory.jobSeeker.completedTrainees;
  return { required, trained, gap: Math.max(0, required - trained) };
}
 
export interface FineReading {
  source: string;
  amountSar: number;
}
 
// What a covered firm risks if it files nothing. Two Ministry figures exist,
// so we return both and let a person choose which one to budget on.
export function nonFilingExposure(headcount: number): FineReading[] {
  if (headcount < 50) return [];
  const tiered = headcount < 500 ? 5_000 : headcount <= 3_000 ? 10_000 : 15_000;
  return [
    { source: "Decision 112377, item 50 (schedule in force)", amountSar: 5_000 },
    { source: "HRSD notice of 10 Nov 2025, first offence", amountSar: tiered },
  ];
}

في الغرامة، تختلف الأرقام المنشورة. حدد إعلان 10 نوفمبر 2025 مبالغ 5,000 أو 10,000 أو 15,000 ريال حسب الحجم، تتضاعف عند التكرار. ثم حلّ القرار 112377 بتاريخ 21/8/1447هـ (9 فبراير 2026) محل جدول المخالفات كله. البند 50 فيه يحدد 5,000 ريال للفئة (أ) ولا شيء فوقها، والفقرة 9 تلغي ما يتعارض معه. قراءتنا أن جدول 2026 هو النافذ. لم نجد بيانًا من الوزارة يقول ذلك صراحة، لذلك تعرض الأداة الرقمين وتترك قرار الميزانية لشخص مسؤول. ولاحظ أيضًا أن شرائح 2025 لا تحدد منشأة فيها 3,000 عامل بالضبط، فتضعها الشيفرة في الشريحة الوسطى.

وقد تستفيد المخالفة الأولى من التخفيض بنسبة 80% وفق القرار 93442. يشرح دليلنا لجدول المخالفات 112377 شروطه.

الخطوة 7: التشغيل على بياناتك

ضع المدخلات الأربعة في ملف JSON واحد. قائمة العاملين فارغة هنا لاختصار الصفحة؛ الصق فيها ملف تصدير السجل كاملًا، كائنًا لكل عامل كما في النوع Worker.

{
  "unifiedNumber": "7001234567",
  "workers": [],
  "programmes": [
    { "programmeId": "P1", "title": "Excel for finance", "provider": "in-house", "internal": true },
    { "programmeId": "P2", "title": "Fire safety", "provider": "Riyadh Safety Institute", "internal": false }
  ],
  "records": [
    { "traineeId": "1012345678", "category": "employee", "programmeId": "P1", "startDate": "2026-03-01", "endDate": "2026-03-05", "hours": 10, "costSar": 0, "completed": true },
    { "traineeId": "1012345678", "category": "employee", "programmeId": "P2", "startDate": "2026-09-14", "endDate": "2026-09-14", "hours": 6, "costSar": 450, "completed": true },
    { "traineeId": "2098765432", "category": "employee", "programmeId": "P2", "startDate": "2026-09-14", "endDate": "2026-09-14", "hours": 6, "costSar": 450, "completed": true },
    { "traineeId": "G-0001", "category": "graduate", "programmeId": "P1", "startDate": "2026-07-01", "endDate": "2026-08-31", "hours": 120, "costSar": 3000, "completed": true }
  ]
}

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

import { readFileSync } from "node:fs";
import { buildDisclosure } from "./aggregate.ts";
import { isCovered } from "./coverage.ts";
import { graduateCheck, nonFilingExposure } from "./exposure.ts";
import type { Policy, Programme, TrainingRecord, Worker } from "./types.ts";
import { validateRecords } from "./validate.ts";
 
interface Input {
  unifiedNumber: string;
  workers: Worker[];
  programmes: Programme[];
  records: TrainingRecord[];
}
 
const policy: Policy = {
  year: 2026,
  hoursPerUnit: 1, // ASSUMPTION: replace with the Ministry's definition
  minUnitsPerTrainee: 8,
  graduateRate: 0.02,
};
 
const path = process.argv[2];
if (!path) {
  console.error("usage: npx tsx src/report.ts training-2026.json");
  process.exit(1);
}
const input = JSON.parse(readFileSync(path, "utf8")) as Input;
 
const cover = isCovered(input.workers, input.unifiedNumber, policy.year);
console.log(`Head count on ${cover.asOf}: ${cover.headcount}`);
if (!cover.covered) {
  console.log("Below 50 workers: the disclosure duty does not apply.");
  process.exit(0);
}
 
const problems = validateRecords(input.records, input.programmes, policy.year);
if (problems.length > 0) {
  for (const p of problems) console.log(`row ${p.index}: ${p.code} ${p.message}`);
  console.log(`${problems.length} problem(s). Fix the source data, then run again.`);
  process.exit(2);
}
 
const d = buildDisclosure(input.records, policy);
if (d.nil) {
  console.log("No completed training: file the nil declaration in Qiwa.");
} else {
  console.log(`Programmes: ${d.programmes}`);
  for (const [category, t] of Object.entries(d.byCategory)) {
    console.log(
      `${category.padEnd(10)} trainees ${t.completedTrainees}  hours ${t.hours}  cost SAR ${t.costSar}`,
    );
  }
  console.log(`Total hours ${d.totalHours}, total cost SAR ${d.totalCostSar}`);
  for (const b of d.belowMinimum) {
    console.log(`below minimum: ${b.traineeId} has ${b.units} unit(s)`);
  }
}
 
const g = graduateCheck(d, cover.headcount, policy);
console.log(`Graduates and job seekers: ${g.trained} of ${g.required} (gap ${g.gap})`);
for (const f of nonFilingExposure(cover.headcount)) {
  console.log(`If not filed: SAR ${f.amountSar} per ${f.source}`);
}

مع 120 عاملًا في السجل والسطور الأربعة في المثال، يكون الناتج:

Head count on 2026-12-31: 120
Programmes: 2
employee   trainees 2  hours 22  cost SAR 900
student    trainees 0  hours 0  cost SAR 0
graduate   trainees 1  hours 120  cost SAR 3000
jobSeeker  trainees 0  hours 0  cost SAR 0
Total hours 142, total cost SAR 3900
below minimum: 2098765432 has 6 unit(s)
Graduates and job seekers: 1 of 3 (gap 2)
If not filed: SAR 5000 per Decision 112377, item 50 (schedule in force)
If not filed: SAR 5000 per HRSD notice of 10 Nov 2025, first offence

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

الخطوة 8: اختبار القواعد

لكل قاعدة في الجدول أعلاه اختبار، بما في ذلك الحدود: 49 مقابل 50 عاملًا، وعقد ينتهي قبل نهاية السنة بيوم، وإعداد حجم الوحدة، وتقريب نسبة 2%.

import assert from "node:assert/strict";
import { test } from "node:test";
import { buildDisclosure } from "../src/aggregate.ts";
import { headcountOn, isCovered } from "../src/coverage.ts";
import { graduateCheck, nonFilingExposure } from "../src/exposure.ts";
import type { Policy, Programme, TrainingRecord, Worker } from "../src/types.ts";
import { validateRecords } from "../src/validate.ts";
 
const policy: Policy = { year: 2026, hoursPerUnit: 1, minUnitsPerTrainee: 8, graduateRate: 0.02 };
 
function staff(n: number, unifiedNumber = "7001"): Worker[] {
  return Array.from({ length: n }, (_, i) => ({
    workerId: `W${i}`,
    unifiedNumber,
    startDate: "2025-01-01",
  }));
}
 
const catalogue: Programme[] = [
  { programmeId: "P1", title: "Excel for finance", provider: "in-house", internal: true },
  { programmeId: "P2", title: "Safety", provider: "External Co", internal: false },
];
 
function rec(over: Partial<TrainingRecord>): TrainingRecord {
  return {
    traineeId: "T1",
    category: "employee",
    programmeId: "P1",
    startDate: "2026-03-01",
    endDate: "2026-03-05",
    hours: 10,
    costSar: 1000,
    completed: true,
    ...over,
  };
}
 
test("coverage starts at 50 workers on 31 December", () => {
  assert.equal(isCovered(staff(49), "7001", 2026).covered, false);
  assert.equal(isCovered(staff(50), "7001", 2026).covered, true);
});
 
test("a worker who left before year end is not counted", () => {
  const w = staff(50);
  w[0] = { ...w[0], endDate: "2026-12-30" };
  assert.equal(headcountOn(w, "7001", "2026-12-31"), 49);
});
 
test("the same worker listed twice counts once", () => {
  const w = [...staff(50), { workerId: "W0", unifiedNumber: "7001", startDate: "2026-06-01" }];
  assert.equal(headcountOn(w, "7001", "2026-12-31"), 50);
});
 
test("other unified numbers are ignored", () => {
  const w = [...staff(30), ...staff(30, "7002")];
  assert.equal(headcountOn(w, "7001", "2026-12-31"), 30);
});
 
test("validation reports every bad row", () => {
  const problems = validateRecords(
    [
      rec({}),
      rec({ endDate: "2027-01-02", startDate: "2026-12-20" }),
      rec({ hours: 0, startDate: "2026-04-01", endDate: "2026-04-01" }),
      rec({ programmeId: "P9", startDate: "2026-05-01", endDate: "2026-05-01" }),
      rec({}),
    ],
    catalogue,
    2026,
  );
  assert.deepEqual(
    problems.map((p) => [p.index, p.code]),
    [
      [1, "OUTSIDE_YEAR"],
      [2, "BAD_HOURS"],
      [3, "UNKNOWN_PROGRAMME"],
      [4, "DUPLICATE"],
    ],
  );
});
 
test("no completed training means a nil declaration", () => {
  const d = buildDisclosure([rec({ completed: false })], policy);
  assert.equal(d.nil, true);
  assert.equal(d.totalHours, 0);
});
 
test("trainees are counted once per category, hours and cost summed", () => {
  const d = buildDisclosure(
    [
      rec({}),
      rec({ programmeId: "P2", hours: 6, costSar: 450.5 }),
      rec({ traineeId: "G1", category: "graduate", hours: 40, costSar: 0 }),
    ],
    policy,
  );
  assert.equal(d.byCategory.employee.completedTrainees, 1);
  assert.equal(d.byCategory.employee.hours, 16);
  assert.equal(d.byCategory.employee.costSar, 1450.5);
  assert.equal(d.byCategory.graduate.completedTrainees, 1);
  assert.equal(d.programmes, 2);
  assert.equal(d.totalCostSar, 1450.5);
});
 
test("trainees under eight units are flagged, and the unit size matters", () => {
  const records = [rec({ hours: 6 }), rec({ traineeId: "T2", hours: 9 })];
  assert.deepEqual(buildDisclosure(records, policy).belowMinimum, [{ traineeId: "T1", units: 6 }]);
  const halfDays = buildDisclosure(records, { ...policy, hoursPerUnit: 4 });
  assert.deepEqual(
    halfDays.belowMinimum.map((b) => b.traineeId),
    ["T1", "T2"],
  );
});
 
test("graduate target rounds up", () => {
  const d = buildDisclosure([rec({ traineeId: "G1", category: "graduate" })], policy);
  assert.deepEqual(graduateCheck(d, 120, policy), { required: 3, trained: 1, gap: 2 });
  assert.equal(graduateCheck(d, 50, policy).required, 1);
});
 
test("both fine readings are returned for covered firms only", () => {
  assert.deepEqual(nonFilingExposure(49), []);
  assert.deepEqual(
    nonFilingExposure(600).map((f) => f.amountSar),
    [5_000, 10_000],
  );
  assert.equal(nonFilingExposure(3_001)[1].amountSar, 15_000);
  assert.equal(nonFilingExposure(499)[1].amountSar, 5_000);
});

شغّلها بالأمر:

npx tsx --test test/disclosure.test.ts

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

حل المشكلات

العدد لا يطابق قوى. على الأرجح يتضمن ملف التصدير عاملين مسجلين تحت رقم موحد آخر، أو تنقصه تواريخ انتهاء لمن غادروا. قارن المعرّفات لا المجاميع فقط.

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

الخريجون يظهرون صفرًا. طلاب التدريب التعاوني والخريجون المتدربون يتابعهم غالبًا فريق آخر خارج نظام الموارد البشرية. وهم فئات مستقلة في القرار، فاطلب قائمتهم صراحة.

التكاليف ناقصة في الدورات الداخلية. أدخل 0 واحتفظ بملاحظة عن وقت المدرّب إن أردت الإفصاح عنه لاحقًا. لا تترك الحقل فارغًا.

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

الخلاصة

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

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