اعتباراً من 18 نوفمبر 2026، يجب على كل مركز أو صالة رياضية في القطاع الخاص بالمملكة يعمل فيها أربعة عاملين فأكثر في اثنتي عشرة مهنة تدريبية أن يكون 15% من هؤلاء العاملين سعوديين. القرار هو القرار الوزاري رقم 72002 وتاريخ 1447/05/27هـ، الصادر عن وزارة الموارد البشرية والتنمية الاجتماعية بالشراكة مع وزارة الرياضة، وفترة السماح البالغة اثني عشر شهراً تنتهي في ذلك اليوم. تناولنا الإعلان في خبر الصالات الرياضية السعودية مطالبة بتوطين 15% من 12 مهنة تدريبية.
النسبة هي الجزء السهل. أما الدليل الإجرائي للقرار فيحسم ثلاث مسائل تحدد هل تقول لوحة المتابعة الحقيقة أم لا: الاحتساب يتم لكل منفذ مرخّص لا لكل شركة، وحدّ العاملين الأربعة يحتسب المهن المستهدفة وحدها، والناتج يُقرَّب إلى أقرب عدد صحيح وفق جدول مطبوع يمكن الاختبار عليه. قد تكون السلسلة ملتزمة في مجموعها ومخالفة في عدة فروع.
يبني هذا الدرس المحرّك الذي يجيب عن السؤال لكل منفذ، ثم على مستوى السلسلة كلها.
المتطلبات المسبقة
- Node.js 20 أو أحدث و TypeScript 5.x
- صلاحية قراءة رمز المهنة المسجّل في عقد كل عامل على منصة قوى، لكل ترخيص من وزارة الرياضة
- تصدير جدول الحصص أو حجوزات التدريب الشخصي، إن أردت فحص المدربين المستترين في الخطوة 5
- الاطلاع على مشكلة التقريب في محرّك توطين المهن المحاسبية مفيد، لكنه ليس شرطاً
ما الذي ستبنيه
وحدة برمجية خالصة بلا أي اعتماديات تجيب عن خمسة أسئلة لأي منفذ وأي تاريخ:
- هل المنفذ مشمول، بحسب تسجيله أو بحسب نشاطه الفعلي؟
- كم عاملاً في المهن الاثنتي عشرة المستهدفة، وكم سعودياً يجب أن يكون بينهم؟
- هل بدأ تطبيق العقوبات، أم ما زلنا في فترة السماح؟
- من يمارس التدريب دون أن تكون مهنة تدريبية مسجّلة في عقده؟
- على مستوى السلسلة: أي المدربين السعوديين يمكن نقلهم بين الفروع، وكم يكلّف الباقي استبدالاً أو توظيفاً؟
كل ثابت في الكود مأخوذ من الدليل الإجرائي الذي نزّلناه من موقع hrsd.gov.sa في 2 نوفمبر 2026، وكل مخرج أدناه ناتج عن تشغيل الكود فعلاً.
الخطوة 1: ماذا يقول الدليل فعلاً
أربع قواعد من الدليل الإجرائي تحدد شكل المحرّك كله.
الوحدة هي المنفذ. يعرّف الدليل المنفذ الرياضي بأنه أي كيان اعتباري حاصل على سجل تجاري ويرغب في ممارسة نشاط إدارة وتشغيل المراكز والصالات الرياضية، وحاصل على ترخيص مستقل من وزارة الرياضة، ويَعُدّ كل مركز أو صالة تحمل رخصة مستقلة منفذاً مستقلاً لأغراض تطبيق القرار. الفروع لا تُجمَع.
الحدّ يحتسب العاملين في المهن المستهدفة. يطبّق البند 3.2 القرار على المنافذ التي يبلغ إجمالي عدد العاملين بها في المهن المحددة في الجدول 2 أربعة فأكثر. المنفذ الذي فيه ثلاثة مدربين وعشرون موظف استقبال ونظافة ومحاسبة خارج النطاق. وتكرّر الأسئلة الشائعة القراءة نفسها: أربعة عمال فأكثر يعملون في مهنة رياضية محددة في الدليل.
النطاق يتبع النشاط الفعلي. يشمل القرار نشاطي التصنيف الوطني للأنشطة الاقتصادية 931221 (الصالات والمراكز الرياضية الرجالية) و931222 (النسائية). ويمدّه البند 3.2 إلى أي منفذ رياضي يمارس أحد هذين النشاطين بسجل تجاري أو ترخيص بنشاط مختلف، وتؤكد الأسئلة الشائعة أن العبرة بالنشاط الفعلي بغض النظر عما في السجل التجاري أو الترخيص البلدي.
التقريب إلى أقرب عدد صحيح. يطبع الدليل جدولاً من 4 إلى 40 عاملاً في المهن المستهدفة ويذكر القاعدة: من 0.49 فأقل يُقرَّب إلى الصفر، ومن 0.50 فأعلى يُقرَّب إلى واحد. مثاله التطبيقي منفذ فيه 23 عاملاً، منهم 6 مدربين رياضيين و4 مدربي لياقة بدنية في المهن المستهدفة: 10 × 15% = 1.5، فالمطلوب سعوديان. مدير المبيعات والمحاسبون وموظفو الاستقبال وحراس الأمن خارج الاحتساب.
ما لا يقوله الدليل، ولا يخترعه هذا المحرّك: حدّ أدنى للأجر أو شرط للتأمينات الاجتماعية حتى يُحتسب السعودي، ومبلغ الغرامة (يحيل الدليل إلى جدول المخالفات والعقوبات الصادر بالقرارين الوزاريين 75913 و44558)، وهل يحتاج نقل عامل بين ترخيصين للشركة نفسها إلى معاملة في قوى. نشير إلى كل ذلك حيث يهم.
الخطوة 2: الجداول المرجعية
ضع كل الثوابت في ملف واحد مع مصدرها في تعليق. الرموز الاثنا عشر مأخوذة من التصنيف السعودي الموحد للمهن كما وردت في الجدول 2 من الدليل.
أنشئ الملف src/rules.ts:
// Ministerial Decision 72002 of 27/05/1447 AH and its procedural guide (HRSD).
/** Saudis required as a share of workers in the targeted occupations. */
export const RATE_PERCENT = 15;
/** The decision bites only at four or more workers in the targeted occupations. */
export const MIN_TARGETED_WORKERS = 4;
/** End of the 12-month grace period: 18/11/2026 = 8/6/1448 AH. */
export const ENFORCEMENT_FROM = '2026-11-18';
/** ISIC4 activity codes, guide table 1. */
export const TARGETED_ACTIVITIES: Readonly<Record<string, string>> = {
'931221': "Men's sports halls and centres",
'931222': "Women's sports halls and centres",
};
/** Saudi Standard Classification of Occupations codes, guide table 2. */
export const TARGETED_OCCUPATIONS: Readonly<Record<string, string>> = {
'342201': 'Sports coach',
'342203': 'Professional football coach',
'342205': 'Professional ball sports coach',
'342207': 'Professional self-defence coach',
'342209': 'Professional racket sports coach',
'342211': 'Professional aquatic sports coach',
'342213': 'Professional ice sports coach',
'342217': 'Professional equestrian coach',
'342219': 'Professional athletics coach',
'342221': 'Sports supervisor',
'342301': 'Fitness trainer',
'342302': 'Personal trainer',
};
export function isTargetedOccupation(code: string): boolean {
return Object.prototype.hasOwnProperty.call(TARGETED_OCCUPATIONS, code);
}رمز المهنة المعتمد هو المسجّل في العقد، لا المسمى المستخدم داخل الصالة. المدرب الأول المسجّل مشرفاً رياضياً يُحتسب على الرمز 342221. والمدرب المسجّل موظف استقبال لا يُحتسب إطلاقاً، وهذه هي المشكلة التي تعالجها الخطوة 5.
الخطوة 3: التقريب دون أعداد عشرية
قاعدة الدليل هي التقريب إلى الأقرب مع رفع النصف. يبدو Math.round(n * 0.15) صحيحاً لكنه عادة خاطئة: العدد 0.15 لا يُمثَّل بدقة في النظام الثنائي، وقد أظهر محرّك التوطين المحاسبي النمط نفسه يُسقط شخصاً كاملاً عند نسبة 70%. أبقِ الحساب بالأعداد الصحيحة: النسبة المئوية عدد صحيح، فاضرب أولاً ثم أضف نصف المقسوم عليه ثم اقسم.
أنشئ الملف src/required.ts:
import { MIN_TARGETED_WORKERS, RATE_PERCENT } from './rules';
/**
* Saudis required in one outlet. The guide rounds to the nearest whole
* number: 0.49 and below goes down, 0.50 and above goes up. Integer-only,
* so no float ever enters.
*/
export function requiredSaudis(targetedWorkers: number): number {
if (!Number.isInteger(targetedWorkers) || targetedWorkers < 0) {
throw new RangeError(`targetedWorkers must be a whole number, got ${targetedWorkers}`);
}
if (targetedWorkers < MIN_TARGETED_WORKERS) return 0;
return Math.floor((targetedWorkers * RATE_PERCENT + 50) / 100);
}الحدّ موجود هنا لا عند المستدعي. تحت أربعة عاملين في المهن المستهدفة لا يُطلب شيء، وإرجاع الصفر يُبقي بقية المحرّك بسيطة. عند أربعة بالضبط، 4 × 15% = 0.6 تُقرَّب إلى واحد، وهذا يطابق الأسئلة الشائعة في الدليل: المنفذ الذي فيه أربعة عمال في مهنة رياضية يجب أن يوظف سعودياً واحداً على الأقل.
الخطوة 4: تقييم منفذ واحد
يحتاج التقييم إلى تسجيل المنفذ، ونشاطه الفعلي، وقائمة عامليه. الحالة واحدة من أربع قيم، وترتيب الفحوص مهم: النشاط أولاً، ثم الحدّ، ثم النسبة.
أنشئ الملف src/outlet.ts:
import { ENFORCEMENT_FROM, TARGETED_ACTIVITIES, isTargetedOccupation } from './rules';
import { requiredSaudis } from './required';
export interface Worker {
id: string;
/** The occupation code registered on the contract in Qiwa, not the floor title. */
occupationCode: string;
saudi: boolean;
}
export interface Outlet {
/** Ministry of Sport licence number. One licence = one outlet. */
licenceNo: string;
name: string;
/** ISIC4 activity on the commercial registration or licence. */
registeredActivity: string;
/** What the outlet actually runs, in ISIC4 codes. */
actualActivities: string[];
workers: Worker[];
}
export type OutletStatus = 'OUT_OF_SCOPE_ACTIVITY' | 'BELOW_THRESHOLD' | 'COMPLIANT' | 'SHORT';
export interface OutletAssessment {
licenceNo: string;
status: OutletStatus;
targeted: number;
saudis: number;
required: number;
gap: number;
/** In scope only because of what it actually does, not what it registered. */
scopeFromActualActivity: boolean;
/** False during the grace period: the rate applies, the penalties do not. */
enforceable: boolean;
}
const isTargetedActivity = (code: string): boolean =>
Object.prototype.hasOwnProperty.call(TARGETED_ACTIVITIES, code);
export function assessOutlet(outlet: Outlet, asOf: string): OutletAssessment {
const byRegistration = isTargetedActivity(outlet.registeredActivity);
const byActual = outlet.actualActivities.some(isTargetedActivity);
const targetedWorkers = outlet.workers.filter((w) => isTargetedOccupation(w.occupationCode));
const targeted = targetedWorkers.length;
const saudis = targetedWorkers.filter((w) => w.saudi).length;
const required = requiredSaudis(targeted);
const gap = Math.max(0, required - saudis);
let status: OutletStatus;
if (!byRegistration && !byActual) status = 'OUT_OF_SCOPE_ACTIVITY';
else if (required === 0) status = 'BELOW_THRESHOLD';
else status = gap === 0 ? 'COMPLIANT' : 'SHORT';
return {
licenceNo: outlet.licenceNo,
status,
targeted,
saudis,
required: status === 'OUT_OF_SCOPE_ACTIVITY' ? 0 : required,
gap: status === 'SHORT' ? gap : 0,
scopeFromActualActivity: !byRegistration && byActual,
enforceable: asOf >= ENFORCEMENT_FROM,
};
}حقلان يستحقان مكانهما. الحقل scopeFromActualActivity يكون صحيحاً للمنفذ المسجّل بنشاط آخر لكنه يشغّل صالة رياضية فعلاً، كصالة تعمل تحت ترخيص تجزئة أو فندق مثلاً. اعرضه منفصلاً، لأن من يقرأ اللوحة سيفترض أن المنفذ معفى. والحقل enforceable يكون خاطئاً قبل 18 نوفمبر 2026: خلال فترة السماح يُكتفى بتطبيق النسبة دون الإجراءات والعقوبات النظامية، وبعدها تقول الأسئلة الشائعة إن الالتزام يُطبَّق بشكل فوري.
سلاسل التاريخ بصيغة ISO تُقارَن نصياً بشكل صحيح، ولهذا لا تحتاج المقارنة في الكود إلى تحليل التاريخ. مرّر التاريخ صراحة؛ فمحرّك امتثال يقرأ ساعة النظام لا يمكن اختباره ولا إعادة تشغيله.
الخطوة 5: مدربون بمسميات أخرى
البند 7 من الدليل هو الذي ستتعثر عنده معظم الصالات. ينص على أنه في حال عدم تقيّد المنفذ بالنسبة المطلوبة، أو إسناد أي من مهام المهن الموطّنة إلى عامل غير سعودي بطريقة مباشرة أو غير مباشرة تحت أي مسمى وظيفي آخر، فللوزارة أن تتخذ كافة الإجراءات اللازمة لضمان التزام المنشآت بتنفيذ القرار.
قائمة العاملين لا تُظهر ذلك، لكن جدول الحصص يُظهره. موظف الاستقبال الذي يقدّم حصة الدراجات في السابعة مساءً، أو "مستشار المبيعات" الذي يدير جلسات تدريب شخصي، يظهر في نظام الحجوزات بصفة مدرب. اربط المصدرين.
أنشئ الملف src/disguised.ts:
import { isTargetedOccupation } from './rules';
import { Outlet, OutletAssessment, Worker, assessOutlet } from './outlet';
/** One coaching act from the class timetable or the PT booking system. */
export interface Session {
licenceNo: string;
instructorId: string;
}
/**
* Non-Saudis registered outside the 12 codes who nonetheless lead sessions.
* Section 7 of the guide treats giving them a targeted job's tasks, under
* any other title, as a breach.
*/
export function disguisedCoaches(outlet: Outlet, sessions: Session[]): Worker[] {
const leading = new Set(
sessions.filter((s) => s.licenceNo === outlet.licenceNo).map((s) => s.instructorId),
);
return outlet.workers.filter(
(w) => !w.saudi && !isTargetedOccupation(w.occupationCode) && leading.has(w.id),
);
}
/** Re-assess as an inspector would if each disguised coach counted as a sports coach. */
export function exposure(outlet: Outlet, sessions: Session[], asOf: string): OutletAssessment {
const flagged = new Set(disguisedCoaches(outlet, sessions).map((w) => w.id));
const workers = outlet.workers.map((w) =>
flagged.has(w.id) ? { ...w, occupationCode: '342201' } : w,
);
return assessOutlet({ ...outlet, workers }, asOf);
}تجيب الدالة exposure عن السؤال الذي سيطرحه المفتش: لو احتُسب هؤلاء على ما يعملونه فعلاً، هل يبقى المنفذ ملتزماً؟ احتسابهم على الرمز 342201 (مدرب رياضي) قراءة منا لا من الدليل، وهي مذكورة في الكود. يكفي أن تكون دقيقة بما يكفي لبيان هل تتغير حالة المنفذ.
ملاحظتان. السعودي الذي يقدّم حصصاً وهو مسجّل خارج الرموز التدريبية لا يُعلَّم، لأن المخالفة هي إسناد المهام إلى غير سعودي. وسجلّ الحصة قرينة لا دليل قاطع: حصة بديلة عارضة قد يكون لها تفسير. المخرج قائمة للمراجعة لا حكم.
الخطوة 6: الاستبدال والتوظيف والمقام الذي يتغير
حين يكون المنفذ مقصّراً، هناك طريقتان لسدّ الفجوة، وهما رقمان مختلفان. استبدال غير سعودي في مقعد مستهدف يرفع البسط وحده. أما توظيف سعودي إضافي فيرفع البسط والمقام معاً، وعند بعض الأعداد يرفع العامل الإضافي المطلوب بواحد.
والمنطق نفسه يعمل في الاتجاه المعاكس. خروج سعودي من منفذ يُنقص المقام أيضاً، فالمنفذ الذي فيه 20 عاملاً مستهدفاً منهم 5 سعوديين يمكنه الاستغناء عن اثنين لا ثلاثة مع بقائه ملتزماً.
أنشئ الملف src/gap.ts:
import { requiredSaudis } from './required';
export interface Remediation {
/** Replace non-Saudis in targeted seats: the denominator stays put. */
byReplacement: number;
/** Add Saudis on top: every hire also grows the denominator. */
byHiringOnly: number;
}
export function remediation(targeted: number, saudis: number): Remediation {
const byReplacement = Math.max(0, requiredSaudis(targeted) - saudis);
let byHiringOnly = 0;
while (saudis + byHiringOnly < requiredSaudis(targeted + byHiringOnly)) byHiringOnly++;
return { byReplacement, byHiringOnly };
}
/**
* How many Saudis in targeted seats could leave this outlet with it still
* meeting the rate. Each departure shrinks the denominator as well.
*/
export function spareSaudis(targeted: number, saudis: number): number {
let k = 0;
while (k < saudis && saudis - (k + 1) >= requiredSaudis(targeted - (k + 1))) k++;
return k;
}الحالة التي تفاجئ الناس هي 16. المنفذ الذي فيه 16 عاملاً مستهدفاً يحتاج إلى سعوديين اثنين (2.4 تُقرَّب إلى الأدنى). إن كان لديه واحد فالفجوة واحد. وظّف سعودياً واحداً فيصبح العدد 17 ويصبح المطلوب 3 (2.55 تُقرَّب إلى الأعلى)، فيبقى المنفذ مقصّراً بواحد. الاستدعاء remediation(16, 1) يُرجع استبدالاً واحداً أو توظيفين. ويُظهر جدول الدليل الحواف نفسها: من 16 إلى 17، ومن 23 إلى 24، ومن 29 إلى 30، ومن 36 إلى 37.
الخطوة 7: التخطيط على مستوى السلسلة
السلسلة التي لديها فائض من المدربين السعوديين في فرع وفجوة في فرع آخر تستطيع غالباً حلّ جزء من المشكلة بالنقل قبل التوظيف. المخطِّط أدناه جشِع: يأخذ المنفذ صاحب أكبر فجوة، ويجد المنفذ صاحب أكبر فائض، وينقل سعودياً واحداً، ثم يعيد تقييم الاثنين ويكرر. وما لا يغطيه النقل يُسعَّر استبدالاً أو توظيفاً.
أنشئ الملف src/chain.ts:
import { isTargetedOccupation } from './rules';
import { Outlet, assessOutlet } from './outlet';
import { Remediation, remediation, spareSaudis } from './gap';
export interface Move {
workerId: string;
from: string;
to: string;
}
export interface ChainPlan {
moves: Move[];
stillShort: Array<{ licenceNo: string } & Remediation>;
}
const clone = (outlets: Outlet[]): Outlet[] =>
outlets.map((o) => ({ ...o, workers: o.workers.map((w) => ({ ...w })) }));
/**
* Greedy plan: move a spare Saudi from the outlet with most room to the
* outlet with the largest gap, re-assess both, repeat. Whatever is left
* is priced as replacement or hiring.
*/
export function planChain(input: Outlet[], asOf: string): ChainPlan {
const outlets = clone(input);
const moves: Move[] = [];
const saudiSeats = outlets.flatMap((o) => o.workers).filter((w) => w.saudi).length;
// Each Saudi moves at most once per pass; the cap makes termination obvious.
for (let pass = 0; pass < saudiSeats; pass++) {
const rows = outlets.map((o) => ({ o, a: assessOutlet(o, asOf) }));
const short = rows
.filter((r) => r.a.status === 'SHORT')
.sort((x, y) => y.a.gap - x.a.gap)[0];
if (!short) break;
const donor = rows
.filter((r) => r.o !== short.o && r.a.status !== 'OUT_OF_SCOPE_ACTIVITY')
.map((r) => ({ ...r, spare: spareSaudis(r.a.targeted, r.a.saudis) }))
.filter((r) => r.spare > 0)
.sort((x, y) => y.spare - x.spare)[0];
if (!donor) break;
const i = donor.o.workers.findIndex((w) => w.saudi && isTargetedOccupation(w.occupationCode));
const [worker] = donor.o.workers.splice(i, 1);
short.o.workers.push(worker);
moves.push({ workerId: worker.id, from: donor.o.licenceNo, to: short.o.licenceNo });
}
const stillShort = outlets
.map((o) => assessOutlet(o, asOf))
.filter((a) => a.status === 'SHORT')
.map((a) => ({ licenceNo: a.licenceNo, ...remediation(a.targeted, a.saudis) }));
return { moves, stillShort };
}لا يعدّل المخطِّط مدخلاته، ولا يأخذ من المنفذ المانح إلا ما تقول spareSaudis إنه يستطيع خسارته. الخوارزمية الجشعة ليست الأمثل لكل سلسلة، لكنها لأحجام سلاسل الصالات تعطي خطة يقرؤها المدير سطراً سطراً، وهذا أهم.
تحقّق قبل نقل أي شخص. لا يقول الدليل هل يحتاج نقل عامل بين ترخيصين من وزارة الرياضة تحت السجل التجاري نفسه إلى معاملة في قوى، ولا متى يظهر النقل في الاحتساب. اعتبر النقل خياراً تخطيطياً، وتأكد من الإجراء عبر حسابك في قوى قبل الاعتماد عليه.
الخطوة 8: تشغيله على سلسلة من ثلاثة فروع
يمثّل المثال سلسلة من ثلاثة منافذ. في RYD-01 ستة عشر عاملاً مستهدفاً ومشرف سعودي واحد. وRYD-02 مركز نسائي فيه 20 عاملاً مستهدفاً منهم خمسة سعوديين. وJED-01 مرخّص كمتجر أدوات رياضية بالتجزئة (477310) لكنه يشغّل صالة رجالية، فيه تسعة عاملين مستهدفين منهم سعودي واحد، وموظفا استقبال، وجدول حصص يُظهر أحد موظفي الاستقبال يقدّم حصة.
أنشئ الملف src/demo.ts:
import { Outlet, assessOutlet } from './outlet';
import { Session, disguisedCoaches, exposure } from './disguised';
import { planChain } from './chain';
const staff = (prefix: string, code: string, n: number, saudi: boolean) =>
Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i + 1}`, occupationCode: code, saudi }));
const chain: Outlet[] = [
{
licenceNo: 'RYD-01', name: 'Riyadh North (men)',
registeredActivity: '931221', actualActivities: ['931221'],
workers: [
...staff('r1-pt-', '342302', 10, false),
...staff('r1-fit-', '342301', 5, false),
...staff('r1-sup-', '342221', 1, true),
...staff('r1-desk-', '422601', 3, false),
],
},
{
licenceNo: 'RYD-02', name: 'Riyadh East (women)',
registeredActivity: '931222', actualActivities: ['931222'],
workers: [
...staff('r2-fit-', '342301', 15, false),
...staff('r2-sa-', '342201', 5, true),
],
},
{
licenceNo: 'JED-01', name: 'Jeddah Corniche',
registeredActivity: '477310', actualActivities: ['477310', '931221'],
workers: [
...staff('j1-pt-', '342302', 8, false),
...staff('j1-sa-', '342201', 1, true),
...staff('j1-desk-', '422601', 2, false),
],
},
];
const sessions: Session[] = [
{ licenceNo: 'JED-01', instructorId: 'j1-desk-1' },
{ licenceNo: 'JED-01', instructorId: 'j1-pt-1' },
];
const asOf = '2026-11-18';
for (const o of chain) {
const a = assessOutlet(o, asOf);
console.log(`${a.licenceNo} ${a.status.padEnd(9)} targeted=${a.targeted} saudis=${a.saudis} required=${a.required} gap=${a.gap}${a.scopeFromActualActivity ? ' (in scope by actual activity)' : ''}`);
}
const jed = chain[2];
const hidden = disguisedCoaches(jed, sessions).map((w) => w.id);
const exp = exposure(jed, sessions, asOf);
console.log(`JED-01 disguised coaches: ${hidden.join(', ')} -> ${exp.status} required=${exp.required} gap=${exp.gap}`);
const plan = planChain(chain, asOf);
for (const m of plan.moves) console.log(`move ${m.workerId}: ${m.from} -> ${m.to}`);
console.log('still short:', JSON.stringify(plan.stillShort));شغّله بالأمر npx tsx src/demo.ts:
RYD-01 SHORT targeted=16 saudis=1 required=2 gap=1
RYD-02 COMPLIANT targeted=20 saudis=5 required=3 gap=0
JED-01 COMPLIANT targeted=9 saudis=1 required=1 gap=0 (in scope by actual activity)
JED-01 disguised coaches: j1-desk-1 -> SHORT required=2 gap=1
move r2-sa-1: RYD-02 -> RYD-01
move r2-sa-2: RYD-02 -> RYD-01
still short: []ثلاث نتائج كان جدول البيانات سيُفلتها. يحتاج RYD-01 إلى نقلين لسدّ فجوة قدرها واحد، لأن النقل الأول يرفع المطلوب من اثنين إلى ثلاثة. وJED-01 مشمول رغم أن تسجيله تجزئة. وJED-01 ملتزم على الورق لكنه يقصّر بمجرد احتساب موظف الاستقبال الذي يدرّب مدرباً.
اختبار التنفيذ
يعطيك الدليل شيئاً نادراً: جدولاً مطبوعاً يمكن تثبيته كبيانات اختبار. انسخه كاملاً واختبر كل عمود، لا عيّنة. وتغطي بقية الاختبارات الحدّ، وقاعدة النشاط الفعلي، وحدّ تاريخ التطبيق، والمقام المتغير، ومخطِّط السلسلة.
أنشئ الملف tests/engine.test.ts:
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { TARGETED_ACTIVITIES, TARGETED_OCCUPATIONS } from '../src/rules';
import { requiredSaudis } from '../src/required';
import { Outlet, assessOutlet } from '../src/outlet';
import { remediation, spareSaudis } from '../src/gap';
import { disguisedCoaches, exposure } from '../src/disguised';
import { planChain } from '../src/chain';
// Guide table 3, transcribed: targeted workers 4..40 -> Saudis required.
const TABLE_3: Array<[number, number]> = [
[4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [9, 1],
[10, 2], [11, 2], [12, 2], [13, 2], [14, 2], [15, 2], [16, 2],
[17, 3], [18, 3], [19, 3], [20, 3], [21, 3], [22, 3], [23, 3],
[24, 4], [25, 4], [26, 4], [27, 4], [28, 4], [29, 4],
[30, 5], [31, 5], [32, 5], [33, 5], [34, 5], [35, 5], [36, 5],
[37, 6], [38, 6], [39, 6], [40, 6],
];
const staff = (prefix: string, code: string, n: number, saudi: boolean) =>
Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i + 1}`, occupationCode: code, saudi }));
const outlet = (licenceNo: string, targeted: number, saudis: number, extra = 0): Outlet => ({
licenceNo, name: licenceNo, registeredActivity: '931221', actualActivities: ['931221'],
workers: [
...staff(`${licenceNo}-n`, '342301', targeted - saudis, false),
...staff(`${licenceNo}-s`, '342201', saudis, true),
...staff(`${licenceNo}-x`, '241101', extra, false),
],
});
test('reference tables have the shape the guide prints', () => {
const codes = Object.keys(TARGETED_OCCUPATIONS);
assert.equal(codes.length, 12);
assert.ok(codes.every((c) => /^342[23]\d{2}$/.test(c)), 'codes are six digits in 3422xx or 3423xx');
assert.deepEqual(Object.keys(TARGETED_ACTIVITIES).sort(), ['931221', '931222']);
});
test('requiredSaudis reproduces guide table 3 for every printed column', () => {
for (const [targeted, saudis] of TABLE_3) assert.equal(requiredSaudis(targeted), saudis, `at ${targeted}`);
});
test('below four targeted workers nothing is required', () => {
assert.equal(requiredSaudis(0), 0);
assert.equal(requiredSaudis(3), 0);
assert.throws(() => requiredSaudis(2.5), RangeError);
});
test('guide example 1: 23 staff, 10 in targeted jobs, 2 Saudis required', () => {
const o: Outlet = {
licenceNo: 'A', name: 'A', registeredActivity: '931221', actualActivities: ['931221'],
workers: [
...staff('sales', '122101', 2, false), ...staff('coach', '342201', 6, false),
...staff('fit', '342301', 4, false), ...staff('acct', '241101', 3, false),
...staff('desk', '422601', 4, false), ...staff('guard', '541401', 4, false),
],
};
const a = assessOutlet(o, '2026-11-18');
assert.equal(o.workers.length, 23);
assert.deepEqual([a.status, a.targeted, a.required, a.gap], ['SHORT', 10, 2, 2]);
});
test('threshold counts targeted workers, not headcount', () => {
const a = assessOutlet(outlet('B', 3, 0, 20), '2026-11-18');
assert.equal(a.status, 'BELOW_THRESHOLD');
});
test('scope follows actual activity even when the registration says otherwise', () => {
const o = { ...outlet('C', 6, 0), registeredActivity: '477310', actualActivities: ['477310', '931222'] };
const a = assessOutlet(o, '2026-11-18');
assert.equal(a.status, 'SHORT');
assert.equal(a.scopeFromActualActivity, true);
const out = assessOutlet({ ...o, actualActivities: ['477310'] }, '2026-11-18');
assert.equal(out.status, 'OUT_OF_SCOPE_ACTIVITY');
});
test('penalties start on 18 November 2026, not the day before', () => {
assert.equal(assessOutlet(outlet('D', 10, 0), '2026-11-17').enforceable, false);
assert.equal(assessOutlet(outlet('D', 10, 0), '2026-11-18').enforceable, true);
});
test('at 16 targeted, one hire does not close a gap of one', () => {
assert.deepEqual(remediation(16, 1), { byReplacement: 1, byHiringOnly: 2 });
assert.deepEqual(remediation(10, 0), { byReplacement: 2, byHiringOnly: 2 });
assert.deepEqual(remediation(20, 3), { byReplacement: 0, byHiringOnly: 0 });
});
test('spare Saudis account for the shrinking denominator', () => {
assert.equal(spareSaudis(20, 5), 2);
assert.equal(spareSaudis(17, 3), 1);
assert.equal(spareSaudis(9, 1), 0);
assert.equal(spareSaudis(4, 1), 1);
});
test('a non-Saudi receptionist who leads classes is flagged and changes the result', () => {
const o = outlet('E', 9, 1);
o.workers.push({ id: 'desk1', occupationCode: '422601', saudi: false });
o.workers.push({ id: 'desk2', occupationCode: '422601', saudi: true });
const sessions = [
{ licenceNo: 'E', instructorId: 'desk1' },
{ licenceNo: 'E', instructorId: 'desk2' },
{ licenceNo: 'OTHER', instructorId: 'E-n1' },
];
assert.deepEqual(disguisedCoaches(o, sessions).map((w) => w.id), ['desk1']);
assert.equal(assessOutlet(o, '2026-11-18').status, 'COMPLIANT');
const x = exposure(o, sessions, '2026-11-18');
assert.deepEqual([x.status, x.targeted, x.required], ['SHORT', 10, 2]);
});
test('chain plan needs two moves to close one gap at 16, and leaves the donor compliant', () => {
const plan = planChain([outlet('R1', 16, 1), outlet('R2', 20, 5)], '2026-11-18');
assert.equal(plan.moves.length, 2);
assert.ok(plan.moves.every((m) => m.from === 'R2' && m.to === 'R1'));
assert.deepEqual(plan.stillShort, []);
});
test('chain plan prices what transfers cannot cover and never mutates its input', () => {
const input = [outlet('S1', 12, 0), outlet('S2', 9, 1)];
const plan = planChain(input, '2026-11-18');
assert.deepEqual(plan.moves, []);
assert.deepEqual(plan.stillShort, [{ licenceNo: 'S1', byReplacement: 2, byHiringOnly: 2 }]);
assert.equal(input[1].workers.length, 9);
});شغّلها بالأمر npx tsx --test tests/engine.test.ts. تنجح الاختبارات الاثنا عشر كلها. وقد عدّلنا المحرّك بسبع طرق متعمدة (التقريب إلى الأعلى بدل رفع النصف، وحدّ خمسة، وتجاهل النشاط الفعلي، وتأخير التطبيق يوماً، وتوظيف يتجاهل المقام، وخروج يتجاهله، وتعليم السعوديين كمدربين مستترين)، وكل تعديل منها يُفشل اختباراً واحداً على الأقل.
استكشاف الأخطاء وإصلاحها
مجموع السلسلة ملتزم لكن الفروع تقصّر. متوقع. القرار يُحتسب لكل ترخيص، وجمع مدربي السلسلة في رقم واحد هو أكثر طريقة شيوعاً للإبلاغ عن التزام غير موجود.
منفذ فيه أربعون موظفاً يعود بالحالة BELOW_THRESHOLD. صحيح إن كان أقل من أربعة منهم مسجّلين في الرموز الاثني عشر. ثم شغّل الخطوة 5: أربعون موظفاً وثلاثة مدربين مسجّلين يعني عادة أن في جدول الحصص مدربين أكثر مما في العقود.
كل العاملين يعودون بأنهم غير مستهدفين. أنت تقرأ المسميات الداخلية في نظام الموارد البشرية بدل رمز المهنة في عقد قوى. الاثنان يتباعدان، والتباعد نفسه هو النتيجة.
تمت الموافقة على التوظيف لكن المنفذ ما زال مقصّراً. راجع الحواف في الخطوة 6. عند 16 و23 و29 و36 عاملاً مستهدفاً، يرفع التوظيف الإضافي المطلوب أيضاً.
الخطوات التالية
- شغّل التقييم ليلياً لكل ترخيص ونبّه عند أي تغيّر في الحالة، لا عند التقصير وحده. انتقال منفذ من دون الحدّ إلى مقصّر لأن مدرباً رابعاً انضم هو التنبيه الذي يهم.
- اسحب قوائم العاملين من المنصة بدل جدول بيانات؛ يشرح مقال ربط أنظمة الموارد البشرية بمنصة قوى من أين تأتي بيانات العقود الموثّقة.
- أبقِ رؤية المنشأة كاملة إلى جانب هذه الرؤية. يذكّر الدليل بأن نطاقات وقرارات توطين المهن الأخرى ما زالت سارية، ومثاله نفسه يضم ثلاثة محاسبين يقعون تحت محرّك توطين المهن المحاسبية. وتُظهر حاسبة نطاقات نطاق الشركة كلها، وهو لا يقول شيئاً عن نسبة الـ 15% في كل فرع.
- حين تظهر المخالفة، يكون أول عرَض مرئي لها غالباً رفض شهادة التوطين في قوى.
الخلاصة
يبدو قرار الصالات الرياضية نسبة واحدة. لكنه عملياً احتساب لكل ترخيص، على اثني عشر رمزاً، من بيانات عقود نادراً ما تطابق جدول الحصص، مع تقريب له حواف لا يكفي عندها توظيف واحد. محرّك يحتسب لكل منفذ، ويختبر نفسه على جدول الدليل ذاته، ويقرأ جدول الحصص بحثاً عن المدربين المستترين، ويخطط للنقل قبل التوظيف، سيُخبر السلسلة بموقعها قبل 18 نوفمبر لا بعد زيارة تفتيش.
إن كنت تدير عدة فروع وتريد هذا الفحص مربوطاً ببيانات قوى ونظام الحجوزات بدل إعادة بنائه يدوياً، احجز جلسة تشخيص مجانية. سننظر في أين تعيش رموز المهن والتراخيص وجداول الحصص لديك، وأين تتوقف عن التطابق.