مصنعٌ فيه مئة موظف، خمسة وعشرون منهم سعوديون. نسبة التوطين ٢٥٪، والنطاق أخضر منخفض في ٢٠٢٦. لم يستقل أحد، ولم يُوظَّف أحد، ولم يتغيّر شيء في المنشأة — وفي ٢٠٢٧ يصبح النطاق أحمر.
هذا ليس خطأً في الحساب، بل هو تصميم البرنامج نفسه. نطاقات المطور استبدل الجدول الثابت القديم بمنحنى، وثوابت ذلك المنحنى ترتفع سنةً بعد سنة. فالمنشأة التي تقف على الخط اليوم تسقط تحته العام القادم دون أن تفعل شيئاً.
أي نظام موارد بشرية يعرض «نسبة التوطين» كرقم واحد لهذا الشهر يخفي هذه الحقيقة عن مديره. وهذا الدليل يبني البديل: محرّك يحسب النطاق بالمعادلة الرسمية، ويعرف مَن يُحتسب فعلاً ومَن لا يُحتسب، ويقول لك في أي سنة تسقط ومَن تحتاج توظيفه قبل ذلك.
غطّينا ربط أنظمة الموارد البشرية بمنصة قوى — توثيق العقود، مستويات التكامل، أخطاء الـ API الشائعة — في دليل تكامل قوى مع أنظمة الموارد البشرية. ذلك المقال يشرح كيف تصل إلى البيانات. وهذا الدليل يشرح ماذا تفعل بها بعد أن تصل.
المتطلبات المسبقة
قبل أن تبدأ، تأكّد من توفّر:
- Node.js 20 فأحدث و TypeScript 5 فأحدث
vitestأو أي مشغّل اختبارات مكافئ- إلمام أساسي بـ discriminated unions ودوال JavaScript الرياضية
- نسخة من الدليل الإجرائي لبرنامج نطاقات المطور الصادر عن وزارة الموارد البشرية والتنمية الاجتماعية — تحديداً المرفق رقم ١، فهو مصدر كل ثابت في هذا الدليل
ما يفعله هذا المحرّك وما لا يفعله. يحسب هذا الكود النسب المنشورة لكيانٍ بنشاطٍ وحجمٍ معيّنين، بالمعادلة نفسها التي تطبّقها الوزارة. لكنه لا يقرأ ملف منشأتك: قوى وحدها تعرف رمز نشاطك المسجَّل، وفترات السماح، والكيانات التابعة، وأعداد الموظفين المعتمدة لديها. فإذا اختلف المحرّك مع قوى، فالمعتمد ما تعرضه قوى. مهمة المحرّك أن يحسب القاعدة ويكشف الفجوة مبكراً، لا أن يحلّ محل المنصة.
ما الذي ستبنيه
وحدةً واحدة مؤلَّفة من ستّ قطع:
- جدول الثوابت — المرفق رقم ١ بصيغة TypeScript مُنمَّطة
- حاسب العتبات — منحنى ص = م × لوغ(س) + ث، مع تصنيف النطاق
- حساب الفجوة — كم سعودياً يلزم فعلاً، لا كم يبدو أنه يلزم
- العدد القابل للاحتساب — من كشف الرواتب إلى الأرقام التي يعترف بها البرنامج
- توقّع السنوات — القراءة نفسها بثوابت ٢٠٢٧ و ٢٠٢٨
- محرّك التنبيهات — الإنذار قبل الهبوط، لا بعده
الخطوة ١: المعادلة، ولماذا لا يصلح الجدول الثابت
كان المطلوب في النسخة القديمة من نطاقات نسبةً تُقرأ من جدول. أمّا نطاقات المطور فيحسبها:
ص = م × لوغ(س) + ث
- ص النسبة الدنيا المطلوبة لذلك النطاق
- م ثابت المنحنى، لكل نشاط ونطاق
- ث ثابت التسوية، لكل نشاط ونطاق وسنة
- س إجمالي موظفي الكيان
- لوغ اللوغاريتم الطبيعي — الدليل ينصّ على «القيمة اللوغاريثمية الطبيعية»، أي
Math.logلاMath.log10
هذه النقطة الأخيرة تستحق التوقّف: استخدام اللوغاريتم العشري بدل الطبيعي يعطي رقماً معقولاً في مظهره وخاطئاً تماماً في نتيجته، ولن يُنبّهك أي اختبار إن لم تكتبه بنفسك.
والنتيجة العملية للمنحنى أنّ النسبة المطلوبة تتحرّك مع حجم المنشأة. في أغلب الأنشطة ترتفع كلما كبر الكيان. وفي المقاولات والنظافة ثابت المنحنى سالب، فتنخفض النسبة كلما كبرت المنشأة. لهذا لا يمكن لأي جدول ثابت أن يجيب، ولهذا يسأل المحرّك عن النشاط لا عن العدد وحده:
| النشاط | إجمالي الموظفين | الحد الأدنى للأخضر المنخفض (٢٠٢٦) |
|---|---|---|
| البنية التحتية لتقنية المعلومات | ٣٠ | ٣٠٫٠٥٪ |
| مقاولات التشييد والبناء | ٣٠ | ١٢٫٩١٪ |
| مقاولات التشييد والبناء | ١٠٠٠ | ١١٫٦١٪ |
نفس العدد، وضعفان ونصف من الالتزام. أي واجهة تعرض «نسبة التوطين المطلوبة» دون أن تعرف النشاط تعرض رقماً مخترعاً.
الخطوة ٢: تنميط المرفق رقم ١
ابدأ بالأنواع. النطاقات مرتّبة، والترتيب جزء من المنطق لا تفصيل عرضي:
// nitaqat/types.ts
export const BAND_ORDER = ['lowGreen', 'midGreen', 'highGreen', 'platinum'] as const;
export type BandKey = (typeof BAND_ORDER)[number];
/** Red is not a threshold — it is where you are when you clear none of them. */
export type BandStatus = BandKey | 'red';
export const NITAQAT_YEARS = [2026, 2027, 2028] as const;
export type NitaqatYear = (typeof NITAQAT_YEARS)[number];
/**
* Annex 1 gives, per activity and band, one curve constant and one levelling
* constant per commitment year.
*/
export type BandConstants = {
m: number;
c: readonly [number, number, number];
};
export type Activity = {
id: string;
label: string;
bands: Record<BandKey, BandConstants>;
};ثم الثوابت. المرفق رقم ١ يحمل ٤١ نشاطاً في ٦٥٦ ثابتاً؛ وثلاثة أنشطة تكفي لهذا الدليل، على أن تُدخل البقية بالطريقة نفسها:
// nitaqat/annex1.ts
import type { Activity } from './types';
export const ACTIVITIES: Record<string, Activity> = {
manufacturing: {
id: 'manufacturing',
label: 'الصناعات',
bands: {
lowGreen: { m: 1.68, c: [15.08, 18.08, 21.08] },
midGreen: { m: 1.87, c: [21.87, 24.87, 27.87] },
highGreen: { m: 2.08, c: [23.97, 26.97, 29.97] },
platinum: { m: 2.08, c: [29.87, 32.87, 35.87] },
},
},
construction: {
id: 'construction',
label: 'مقاولات التشييد والبناء',
bands: {
lowGreen: { m: -0.37, c: [14.17, 16.17, 18.17] },
midGreen: { m: -0.37, c: [16.17, 18.17, 20.17] },
highGreen: { m: 0, c: [17.5, 19.5, 21.5] },
platinum: { m: 0, c: [22.5, 24.5, 26.5] },
},
},
itInfrastructure: {
id: 'itInfrastructure',
label: 'البنية التحتية لتقنية المعلومات',
bands: {
lowGreen: { m: 3.61, c: [17.77, 19.77, 21.77] },
midGreen: { m: 3.61, c: [24.64, 26.64, 28.64] },
highGreen: { m: 3.61, c: [40, 42, 44] },
platinum: { m: 3.61, c: [50, 52, 54] },
},
},
};لاحظ ثابت المقاولات السالب.
m: -0.37ليس خطأً مطبعياً. المقاولات والنظافة هما النشاطان اللذان يخفّ التزامهما مع الحجم، وأي «تصحيح» لهذه الإشارة يكسر المحرّك بصمت.
الخطوة ٣: العتبات وتصنيف النطاق
// nitaqat/thresholds.ts
import { BAND_ORDER, NITAQAT_YEARS } from './types';
import type { Activity, BandKey, BandStatus, NitaqatYear } from './types';
/** The curve applies from six workers up; below that a flat rule governs. */
export const CURVE_MIN_HEADCOUNT = 6;
export function bandThresholds(
activity: Activity,
totalWorkforce: number,
year: NitaqatYear = 2026,
): Record<BandKey, number> {
const yearIndex = Math.max(0, NITAQAT_YEARS.indexOf(year));
// ln(0) is -Infinity and ln of a fraction is negative, so floor the size.
const ln = Math.log(Math.max(totalWorkforce, 1));
const out = {} as Record<BandKey, number>;
for (const band of BAND_ORDER) {
const { m, c } = activity.bands[band];
// Clamped: the curve is an empirical fit, not an identity. A negative
// constant at a small headcount can produce a faithful negative percentage.
out[band] = Math.min(100, Math.max(0, m * ln + c[yearIndex]));
}
return out;
}
/** The highest band a rate actually clears. */
export function classifyBand(
rate: number,
thresholds: Record<BandKey, number>,
): BandStatus {
let status: BandStatus = 'red';
for (const band of BAND_ORDER) {
if (rate >= thresholds[band]) status = band;
}
return status;
}الحدّ الأدنى (Math.max(totalWorkforce, 1)) ليس تجميلاً: منشأة بصفر موظفين تُنتج Math.log(0) === -Infinity، فتصبح كل العتبات -Infinity، ويصنّف المحرّك المنشأة الفارغة بلاتينية. هذا نوع الخطأ الذي يمرّ من المراجعة ويظهر في تقرير للإدارة.
والقصّ عند صفر ومئة له سبب مماثل: المنحنى تقريبٌ إحصائي لا هويّة رياضية، وثابتٌ سالب عند عدد صغير قد يعطي نسبةً تحت الصفر — صحيحة حسابياً، بلا معنى عملياً.
الخطوة ٤: قاعدة المنشآت الصغيرة
من عنده خمسة عاملين فأقل لا تنطبق عليه المعادلة اللوغاريتمية — لكن الالتزام لا يسقط. الوزارة تنصّ: «المنشأة التي لديها عدد العاملين ٥ فأقل يتطلب إضافة عامل سعودي واحد فقط».
الفرق بين «لا ينطبق نطاقات» و«المطلوب سعودي واحد» هو الفرق بين منشأة ممتثلة وأخرى تكتشف المشكلة عند أول طلب تأشيرة:
// nitaqat/small-entity.ts
import { CURVE_MIN_HEADCOUNT } from './thresholds';
export const SMALL_ENTITY_SAUDI_REQUIREMENT = 1;
export type SmallEntityCheck = {
applies: true;
met: boolean;
required: number;
} | null;
/** Null once the curve takes over — the caller should read the band instead. */
export function smallEntityCheck(total: number, saudis: number): SmallEntityCheck {
if (total === 0 || total >= CURVE_MIN_HEADCOUNT) return null;
return {
applies: true,
met: saudis >= SMALL_ENTITY_SAUDI_REQUIREMENT,
required: SMALL_ENTITY_SAUDI_REQUIREMENT,
};
}عندما تشكّ في الاتجاه الذي تخطئ فيه، اختر الاتجاه الذي يبالغ في الالتزام لا الذي يقلّل منه. أن تخبر عميلاً بأنه ملتزم وهو ليس كذلك أسوأ من العكس بكثير.
الخطوة ٥: الفجوة — المقام ينمو معك
هنا يخطئ الجميع تقريباً، بمن فيهم جداول الإكسل التي تدير التوطين في شركات كبيرة.
من عنده ٢٠ سعودياً من ١٠٠ ويريد الوصول إلى ٣٠٪ يحسب: ٣٠ ناقص ٢٠ يساوي ١٠ توظيفات. والصحيح ١٥. لأن كل توظيف سعودي يرفع البسط والمقام معاً: بعد عشر توظيفات يصبح لديك ٣٠ سعودياً من ١١٠، أي ٢٧٫٢٧٪ لا ٣٠٪.
المعادلة الصحيحة:
(س + ن) ÷ (الإجمالي + ن) ≥ الهدف
ومنها: ن ≥ (الهدف × الإجمالي − السعوديون) ÷ (١ − الهدف)
// nitaqat/gap.ts
export type Gap = {
hiresNeeded: number;
nonSaudiReduction: number;
};
export function gapTo(targetPercent: number, saudis: number, total: number): Gap {
const t = targetPercent / 100;
if (t >= 1) throw new RangeError('a 100% target has no finite hiring solution');
const rate = total === 0 ? 0 : (saudis / total) * 100;
if (rate >= targetPercent) return { hiresNeeded: 0, nonSaudiReduction: 0 };
// Hiring lifts both terms: (saudis + x) / (total + x) >= t
const hiresNeeded = Math.max(0, Math.ceil((t * total - saudis) / (1 - t)));
// The other lever — shrink the denominator: saudis / (saudis + y) >= t
const nonSaudis = total - saudis;
const allowedNonSaudis = t === 0 ? Infinity : Math.floor(saudis / t) - saudis;
const nonSaudiReduction = Number.isFinite(allowedNonSaudis)
? Math.max(0, nonSaudis - Math.max(0, allowedNonSaudis))
: 0;
return { hiresNeeded, nonSaudiReduction };
}اعرض الرقمين دائماً. المديرون يتّخذون قراراً مختلفاً حين يرون أن الوصول إلى النطاق التالي يكلّف ثماني توظيفات سعودية أو تقليص ثمانية عشر وافداً؛ وعرض أحد الرقمين وحده يحوّل قراراً إلى أمر واقع.
الخطوة ٦: أي الرؤوس تُحتسب أصلاً
هذه هي الخطوة التي تفصل الحاسبة عن نظام الامتثال. يقف بين كشف رواتبك وبين البسط مرشِّحان اثنان، ولا تطبّق معظم لوحات المعلومات في الموارد البشرية أيًّا منهما.
المرشِّح الأول هو العقد. منذ 15 أبريل 2026 لا يُحتسب الموظف السعودي في نسبة التوطين إلا إذا كان عقده موثَّقًا إلكترونيًا في منصة قوى. فالسعودي الذي يعمل لديك فعلًا، ويتقاضى أجره كل شهر، ومسجَّل في التأمينات الاجتماعية، قد لا يُحتسب مع ذلك لأن عقده لم يُوثَّق قط.
والمرشِّح الثاني هو الأجر، وهو الأكثر كلفة في صمت. فقد رفع قرار وزاري من وزير الموارد البشرية والتنمية الاجتماعية الحدَّ الأدنى للأجر الشهري الذي يُحتسب عنده السعودي في نطاقات إلى 4,000 ريال. ودون ذلك لا يختفي الموظف من كشف الرواتب، بل يصل إلى البسط في صورة كسر:
| الأجر الشهري الخاضع للاشتراك | يُحتسب بواقع |
|---|---|
| 4,000 ريال فأكثر | عامل واحد |
| أكثر من 3,000 ريال وأقل من 4,000 ريال | نصف عامل |
| أقل من 3,000 ريال | لا شيء |
وتحمل عدة فئات حدًّا أدنى خاصًّا بها ومعاملًا خاصًّا بها بدل القاعدة العامة: فـالطالب يُحتسب بنصف عامل عند حدٍّ أدنى قدره 1,500 ريال، والعامل بـدوام جزئي بنصف عامل عند 3,000 ريال، والسعودي من ذوي الإعاقة القادر على العمل يُحتسب بـأربعة عمال. أما سقوف هذه الفئات — أي نسبة من القوى العاملة يجوز أن تكون من الطلاب، أو أن تُحتسب بمعامل الإعاقة — فهي خاصة بكل منشأة وتطبّقها قوى، فامذجة المعامل هنا واترك للمنصة الفصل في السقف.
نظامك للموارد البشرية يعدّ الرؤوس. ونطاقات يعدّ الأوزان. والفارق بين هذين الرقمين هو ما يجعل لوحة المعلومات تكذب:
// nitaqat/countable.ts
/**
* The minimum monthly wage at which a Saudi counts as one worker in Nitaqat,
* raised to SAR 4,000 by ministerial decision of the Minister of Human
* Resources and Social Development. Between SAR 3,000 and SAR 4,000 the
* employee counts as half a worker; below SAR 3,000 they do not count at all.
*/
export const FULL_COUNT_WAGE_SAR = 4000;
export const HALF_COUNT_WAGE_SAR = 3000;
/** Students carry their own floor, well under the general one. */
export const STUDENT_WAGE_FLOOR_SAR = 1500;
/** How the programme weights a head, before the wage is applied. */
export type CountingCategory = 'FULL_TIME' | 'PART_TIME' | 'STUDENT' | 'DISABILITY';
export type EmployeeRecord = {
id: string;
nationality: 'SA' | 'NON_SA';
/** Qiwa contract authentication state, mirrored from the platform. */
contractAuthenticated: boolean;
/** Whether GOSI shows an open contribution record for the period. */
gosiActive: boolean;
/**
* The GOSI contributory wage in SAR — not gross pay, and not basic salary.
* Required on purpose: an optional wage defaults to counting in full, which
* is the exact error this step exists to prevent.
*/
contributoryWageSar: number;
category: CountingCategory;
};
export type CountableWorkforce = {
/** Saudis that actually count toward the ratio, as a weight not a head count. */
saudis: number;
/** The denominator: one head each, plus the bonus weight above one head. */
total: number;
/** Saudis sitting in the denominator but contributing nothing to the numerator. */
uncountedSaudis: string[];
/** Saudis counting as a fraction of a head — usually a wage a little too low. */
partiallyCountedSaudis: string[];
};
/**
* The weight one employee contributes to the Saudization numerator.
* Returns 0 for anyone who does not count, whatever the payroll says.
*/
export function countableWeight(e: EmployeeRecord): number {
if (e.nationality !== 'SA') return 0;
// Since 15 April 2026 an unauthenticated Qiwa contract counts for nothing,
// however high the wage and however long the service.
if (!e.contractAuthenticated) return 0;
const wage = e.contributoryWageSar;
switch (e.category) {
case 'DISABILITY':
return wage >= FULL_COUNT_WAGE_SAR ? 4 : 0;
case 'STUDENT':
return wage >= STUDENT_WAGE_FLOOR_SAR ? 0.5 : 0;
case 'PART_TIME':
return wage >= HALF_COUNT_WAGE_SAR ? 0.5 : 0;
case 'FULL_TIME':
if (wage >= FULL_COUNT_WAGE_SAR) return 1;
if (wage >= HALF_COUNT_WAGE_SAR) return 0.5;
return 0;
}
}
export function countableWorkforce(roster: EmployeeRecord[]): CountableWorkforce {
let saudis = 0;
let total = 0;
const uncountedSaudis: string[] = [];
const partiallyCountedSaudis: string[] = [];
for (const e of roster) {
// No open GOSI contribution record, no place in either term.
if (!e.gosiActive) continue;
// Everyone on an open record occupies one head of the denominator,
// whatever the programme then weights them at in the numerator.
total += 1;
if (e.nationality !== 'SA') continue;
const weight = countableWeight(e);
if (weight === 0) {
uncountedSaudis.push(e.id);
continue;
}
if (weight < 1) partiallyCountedSaudis.push(e.id);
saudis += weight;
// Weight granted above the head itself — the disability multiplier — is
// an incentive, so it lifts both terms rather than only the numerator.
total += Math.max(0, weight - 1);
}
return { saudis, total, uncountedSaudis, partiallyCountedSaudis };
}ثلاثة قرارات في هذا الملف تستحق أن تُقال صراحة.
حقل الأجر إلزامي لا اختياري. فالأجر الاختياري يعني ضمنًا احتساب الموظف كاملًا، وهو بالضبط الخطأ الذي وُجدت هذه الخطوة لمنعه — ويقع في صمت، وعلى سجل الموظف الذي كان إصلاحه هو الأرخص.
والأجر المقصود هو الأجر الخاضع لاشتراك التأمينات الاجتماعية، لا إجمالي المستحقات ولا الراتب الأساسي. وهما في الغالب رقمان مختلفان، وواحد منهما فقط هو الذي تقرؤه الوزارة. ويتناول محرّك اشتراكات التأمينات الاجتماعية مواضع اختلافهما.
الوزن الزائد على الرأس الواحد يرفع الطرفين، والوزن الناقص عنه لا يرفع أيًّا منهما. فاحتساب السعودي من ذوي الإعاقة بأربعة هو حافز، ولذلك تركب الوحدات الثلاث الزائدة في المقام كما في البسط: موظف واحد من هؤلاء بين تسعة غير سعوديين يُقرأ 30.77% لا 10%. أما السعودي الذي يُحتسب بنصف، أو لا يُحتسب أصلًا، فيظل شاغلًا رأسًا كاملًا في المقام. وهذا هو الاتجاه المتحفّظ، وهو يطابق معاملة العقد غير الموثَّق، وهو الاتجاه الصحيح للخطأ إن وقع.
حدّ 3,000 ريال بالضبط يستحق سطرًا من المطابقة. فالقاعدة تقول «أكثر من 3,000 وأقل من 4,000» لنصف الاحتساب، و«أقل من 3,000» لعدم الاحتساب، وهو ما يترك الرقم بحدّه غير منسوب إلى أيٍّ منهما. والشيفرة أعلاه تحتسبه نصفًا. فإن كان أحد موظفيك على هذا الريال بالذات، فقارن بما تعرضه قوى قبل أن تثق بالرقم.
النتيجة التي لا يتوقعها أحد: التعيين الذي يخفض نسبتك
أظهرت الخطوة الخامسة المقام وهو ينمو مع كل تعيين. والأوزان تجعل ذلك أحدّ، ويستحق تنبيهًا. فإضافة رأس واحد بوزن w لا ترفع نسبتك إلا إذا كان w أكبر من نسبتك الحالية معبَّرًا عنها بالكسر:
- السعودي الذي يُعيَّن بأقل من 3,000 ريال يضيف رأسًا ولا يضيف وزنًا، ولذلك يخفض دائمًا نسبة التوطين لديك.
- والسعودي الذي يُعيَّن في نصف النطاق يخفضها كلما كنت أصلًا فوق 50%.
- وإذا كانت عتبة النطاق التي تسعى إليها هي نفسها فوق 50%، فإن التعيين بنصف وزن لن يبلغها أبدًا، مهما بلغ عدد من تعيّنهم.
ودالة gapTo في الخطوة الخامسة تعدّ التعيينات بوزن كامل. فإن كانت الوظائف التي توشك على فتحها تدفع أقل من 4,000 ريال، فالرقم الذي تعيده ليس عدد الأشخاص الذين تحتاجهم — وفي الحالة الأخيرة أعلاه لا وجود لهذا العدد أصلًا.
طابِق دائمًا total وsaudis مع الأعداد التي تعرضها قوى نفسها، وعامِل أي فارق بوصفه حادثة تستوجب التحقيق لا رقمًا يُقرَّب. وحاسبة نطاقات هي أسرع وسيلة لترى ما يفعله الوزن المصحَّح بنطاقك قبل أن تكتب شيئًا من هذه الشيفرة.
الخطوة ٧: السنة التي تسقط فيها
الآن نعود إلى المصنع الذي بدأنا به. ثابت التسوية ث يرتفع ثلاث نقاط كل سنة في أغلب أنشطة الصناعات، والقراءة نفسها بثوابت السنة التالية تكشف المنحدر:
// nitaqat/forecast.ts
import { bandThresholds, classifyBand } from './thresholds';
import { NITAQAT_YEARS } from './types';
import type { Activity, BandStatus, NitaqatYear } from './types';
export type YearOutlook = {
year: NitaqatYear;
rate: number;
band: BandStatus;
lowGreenThreshold: number;
};
/** One unchanging workforce, read against each published year's constants. */
export function ratchetOutlook(
activity: Activity,
saudis: number,
total: number,
): YearOutlook[] {
const rate = total === 0 ? 0 : (saudis / total) * 100;
return NITAQAT_YEARS.map((year) => {
const thresholds = bandThresholds(activity, total, year);
return {
year,
rate: Number(rate.toFixed(2)),
band: classifyBand(rate, thresholds),
lowGreenThreshold: Number(thresholds.lowGreen.toFixed(2)),
};
});
}وناتجها لمصنعنا — ٢٥ سعودياً من ١٠٠:
| السنة | النسبة | الحد الأدنى للأخضر المنخفض | النطاق |
|---|---|---|---|
| ٢٠٢٦ | ٢٥٫٠٠٪ | ٢٢٫٨٢٪ | أخضر منخفض |
| ٢٠٢٧ | ٢٥٫٠٠٪ | ٢٥٫٨٢٪ | أحمر |
| ٢٠٢٨ | ٢٥٫٠٠٪ | ٢٨٫٨٢٪ | أحمر |
والرقم الذي يجعل هذا قابلاً للتصرّف: الوصول إلى عتبة ٢٠٢٧ من وضع اليوم يحتاج توظيفَين اثنين. أمّا انتظار الهبوط إلى الأحمر فيعني تعليق خدمات التأشيرات ونقل الخدمات قبل أن تبدأ التوظيف أصلاً. الفارق بين تنبيهٍ مبكر وأزمةٍ متأخّرة هنا هو موظفان.
الخطوة ٨: التنبيه قبل الحافّة لا عندها
النظام الذي ينبّهك عند الهبوط نظامٌ متأخّر. المطلوب هامش:
// nitaqat/alerts.ts
import { bandThresholds, classifyBand } from './thresholds';
import { ratchetOutlook } from './forecast';
import { FULL_COUNT_WAGE_SAR } from './countable';
import { BAND_ORDER } from './types';
import type { Activity, BandKey, NitaqatYear } from './types';
export type Alert = {
level: 'info' | 'warn' | 'critical';
code: string;
message: string;
};
/** Percentage points between the current rate and the floor it sits on. */
export function bandBuffer(
rate: number,
thresholds: Record<BandKey, number>,
): number {
const current = classifyBand(rate, thresholds);
if (current === 'red') return 0;
return Number((rate - thresholds[current]).toFixed(2));
}
export function reviewCompliance(input: {
activity: Activity;
saudis: number;
total: number;
uncountedSaudis: string[];
partiallyCountedSaudis?: string[];
year?: NitaqatYear;
bufferPoints?: number;
}): Alert[] {
const {
activity, saudis, total, uncountedSaudis, partiallyCountedSaudis = [],
year = 2026, bufferPoints = 2,
} = input;
const alerts: Alert[] = [];
const rate = total === 0 ? 0 : (saudis / total) * 100;
const thresholds = bandThresholds(activity, total, year);
const band = classifyBand(rate, thresholds);
const buffer = bandBuffer(rate, thresholds);
if (band === 'red') {
alerts.push({
level: 'critical',
code: 'BAND_RED',
message: `Red band: ${rate.toFixed(2)}% against a ${thresholds.lowGreen.toFixed(2)}% floor.`,
});
} else if (buffer < bufferPoints) {
alerts.push({
level: 'warn',
code: 'BAND_MARGIN_THIN',
message: `Only ${buffer} points above the ${band} floor — one departure may cost the band.`,
});
}
if (uncountedSaudis.length > 0) {
alerts.push({
level: 'warn',
code: 'CONTRACTS_UNAUTHENTICATED',
message: `${uncountedSaudis.length} Saudi employees have no authenticated Qiwa contract and are not counting.`,
});
}
if (partiallyCountedSaudis.length > 0) {
alerts.push({
level: 'warn',
code: 'WAGE_BELOW_FULL_COUNT',
message: `${partiallyCountedSaudis.length} Saudi employees count as half a head because their contributory wage is under SAR ${FULL_COUNT_WAGE_SAR}.`,
});
}
const falls = ratchetOutlook(activity, saudis, total).find((o) => o.band === 'red');
if (falls && band !== 'red') {
alerts.push({
level: 'critical',
code: 'RATCHET_FALL',
message: `Unchanged, this workforce falls to red in ${falls.year}.`,
});
}
return alerts;
}الهامش الافتراضي نقطتان مئويتان، وهو اختيار وليس قاعدة: في منشأة من مئة موظف، استقالة سعودي واحد تكلّف نحو نقطة مئوية كاملة. اضبط bufferPoints بحسب حجم الكيان لا بحسب ذوقك.
اختبار المحرّك
الاختبارات هنا ليست شكلية. الأرقام الأربعة الأولى مأخوذة من منحنى الصناعات عند مئة موظف، وأي انحراف فيها يعني أن أحدهم بدّل اللوغاريتم أو أخطأ في نسخ ثابت:
// nitaqat/engine.test.ts
import { describe, expect, it } from 'vitest';
import { ACTIVITIES } from './annex1';
import { bandThresholds, classifyBand } from './thresholds';
import { gapTo } from './gap';
import { ratchetOutlook } from './forecast';
import { countableWorkforce } from './countable';
import type { EmployeeRecord } from './countable';
describe('thresholds', () => {
it('computes the 2026 manufacturing curve at 100 employees', () => {
const t = bandThresholds(ACTIVITIES.manufacturing, 100, 2026);
expect(t.lowGreen).toBeCloseTo(22.82, 2);
expect(t.midGreen).toBeCloseTo(30.48, 2);
expect(t.highGreen).toBeCloseTo(33.55, 2);
expect(t.platinum).toBeCloseTo(39.45, 2);
});
it('eases with size where the curve constant is negative', () => {
const small = bandThresholds(ACTIVITIES.construction, 10, 2026).lowGreen;
const large = bandThresholds(ACTIVITIES.construction, 1000, 2026).lowGreen;
expect(small).toBeCloseTo(13.32, 2);
expect(large).toBeCloseTo(11.61, 2);
expect(large).toBeLessThan(small);
});
it('separates two activities that share a headcount', () => {
const it = bandThresholds(ACTIVITIES.itInfrastructure, 30, 2026).lowGreen;
const con = bandThresholds(ACTIVITIES.construction, 30, 2026).lowGreen;
expect(it).toBeCloseTo(30.05, 2);
expect(con).toBeCloseTo(12.91, 2);
});
it('does not call an empty establishment platinum', () => {
const t = bandThresholds(ACTIVITIES.manufacturing, 0, 2026);
expect(Number.isFinite(t.lowGreen)).toBe(true);
expect(classifyBand(0, t)).toBe('red');
});
});
describe('the gap', () => {
it('accounts for the denominator growing with each hire', () => {
expect(gapTo(30.48, 25, 100).hiresNeeded).toBe(8);
});
it('offers the reduction path as well', () => {
expect(gapTo(30.48, 25, 100).nonSaudiReduction).toBe(18);
});
it('returns zero once the target is already met', () => {
expect(gapTo(20, 25, 100)).toEqual({ hiresNeeded: 0, nonSaudiReduction: 0 });
});
});
describe('the ratchet', () => {
it('drops a static workforce a band without anyone moving', () => {
const outlook = ratchetOutlook(ACTIVITIES.manufacturing, 25, 100);
expect(outlook[0].band).toBe('lowGreen');
expect(outlook[1].band).toBe('red');
expect(outlook[1].lowGreenThreshold).toBeCloseTo(25.82, 2);
});
});
describe('countable workforce', () => {
const head = (over: Partial<EmployeeRecord>): EmployeeRecord => ({
id: 'x',
nationality: 'SA',
contractAuthenticated: true,
gosiActive: true,
contributoryWageSar: 6000,
category: 'FULL_TIME',
...over,
});
it('keeps an unauthenticated Saudi in the denominator only', () => {
const w = countableWorkforce([
head({ id: 'a' }),
head({ id: 'b', contractAuthenticated: false }),
head({ id: 'c', nationality: 'NON_SA' }),
]);
expect(w.saudis).toBe(1);
expect(w.total).toBe(3);
expect(w.uncountedSaudis).toEqual(['b']);
});
it('halves a Saudi under the full-count wage and drops one under the lower band', () => {
const w = countableWorkforce([
head({ id: 'mid', contributoryWageSar: 3500 }),
head({ id: 'low', contributoryWageSar: 2800 }),
]);
expect(w.saudis).toBe(0.5);
expect(w.partiallyCountedSaudis).toEqual(['mid']);
expect(w.uncountedSaudis).toEqual(['low']);
});
});والملف الثاني هو الذي ينبغي تشغيله قبل الوثوق بأي نسبة مئوية. كل حالة فيه أجر أو فئة يصيبها تقرير الرواتب ويخطئها نطاقات:
// nitaqat/counting.test.ts
import { describe, expect, it } from 'vitest';
import { countableWeight, countableWorkforce } from './countable';
import type { EmployeeRecord } from './countable';
import { reviewCompliance } from './alerts';
import { ACTIVITIES } from './annex1';
const saudi = (over: Partial<EmployeeRecord> = {}): EmployeeRecord => ({
id: 'x',
nationality: 'SA',
contractAuthenticated: true,
gosiActive: true,
contributoryWageSar: 6000,
category: 'FULL_TIME',
...over,
});
describe('the wage bands', () => {
it('counts a full head at the floor and half a head one riyal under it', () => {
expect(countableWeight(saudi({ contributoryWageSar: 4000 }))).toBe(1);
expect(countableWeight(saudi({ contributoryWageSar: 3999 }))).toBe(0.5);
});
it('counts nothing below the lower band', () => {
expect(countableWeight(saudi({ contributoryWageSar: 2999 }))).toBe(0);
});
it('ignores the wage entirely when the contract is unauthenticated', () => {
expect(
countableWeight(saudi({ contributoryWageSar: 40000, contractAuthenticated: false })),
).toBe(0);
});
});
describe('the category multipliers', () => {
it('counts a disabled Saudi as four, and as nothing under the general floor', () => {
expect(countableWeight(saudi({ category: 'DISABILITY' }))).toBe(4);
expect(
countableWeight(saudi({ category: 'DISABILITY', contributoryWageSar: 3900 })),
).toBe(0);
});
it('applies the student floor rather than the general one', () => {
expect(
countableWeight(saudi({ category: 'STUDENT', contributoryWageSar: 1500 })),
).toBe(0.5);
expect(
countableWeight(saudi({ category: 'STUDENT', contributoryWageSar: 1400 })),
).toBe(0);
});
it('counts a part-timer as half at the part-time floor', () => {
expect(
countableWeight(saudi({ category: 'PART_TIME', contributoryWageSar: 3000 })),
).toBe(0.5);
});
});
describe('the countable workforce', () => {
it('keeps an unauthenticated Saudi in the denominator only', () => {
const w = countableWorkforce([
saudi({ id: 'a' }),
saudi({ id: 'b', contractAuthenticated: false }),
{ ...saudi({ id: 'c' }), nationality: 'NON_SA' },
]);
expect(w.saudis).toBe(1);
expect(w.total).toBe(3);
expect(w.uncountedSaudis).toEqual(['b']);
});
it('separates a Saudi who counts for nothing from one who counts for half', () => {
const w = countableWorkforce([
saudi({ id: 'low', contributoryWageSar: 2800 }),
saudi({ id: 'mid', contributoryWageSar: 3500 }),
]);
expect(w.saudis).toBe(0.5);
expect(w.uncountedSaudis).toEqual(['low']);
expect(w.partiallyCountedSaudis).toEqual(['mid']);
});
it('lifts both terms for the disability multiplier', () => {
const roster = [
saudi({ id: 'd', category: 'DISABILITY' }),
...Array.from({ length: 9 }, (_, i) => ({
...saudi({ id: `n${i}` }),
nationality: 'NON_SA' as const,
})),
];
const w = countableWorkforce(roster);
expect(w.saudis).toBe(4);
expect(w.total).toBe(13);
expect((w.saudis / w.total) * 100).toBeCloseTo(30.77, 2);
});
it('shows a half-weight hire lowering a rate that is already above half', () => {
const before = countableWorkforce([
...Array.from({ length: 6 }, (_, i) => saudi({ id: `s${i}` })),
...Array.from({ length: 2 }, (_, i) => ({
...saudi({ id: `n${i}` }),
nationality: 'NON_SA' as const,
})),
]);
const after = countableWorkforce([
...Array.from({ length: 6 }, (_, i) => saudi({ id: `s${i}` })),
saudi({ id: 'new', contributoryWageSar: 3500 }),
...Array.from({ length: 2 }, (_, i) => ({
...saudi({ id: `n${i}` }),
nationality: 'NON_SA' as const,
})),
]);
expect((before.saudis / before.total) * 100).toBeCloseTo(75, 2);
expect((after.saudis / after.total) * 100).toBeCloseTo(72.22, 2);
expect(after.saudis / after.total).toBeLessThan(before.saudis / before.total);
});
});
describe('the alert', () => {
it('names the half-counted heads and what would fix them', () => {
const alerts = reviewCompliance({
activity: ACTIVITIES.manufacturing,
saudis: 24.5,
total: 100,
uncountedSaudis: [],
partiallyCountedSaudis: ['mid'],
});
const wage = alerts.find((a) => a.code === 'WAGE_BELOW_FULL_COUNT');
expect(wage?.level).toBe('warn');
expect(wage?.message).toContain('SAR 4000');
});
});يمكنك تجربة الأرقام نفسها يدوياً في حاسبة نطاقات قبل أن تثق بمخرجات المحرّك: إن اختلف الاثنان، فأحدهما يحمل خطأً في نسخ ثابت.
أخطاء شائعة
استخدام Math.log10 بدل Math.log. أشيع الأخطاء وأصعبها اكتشافاً، لأن النتيجة تبقى نسبةً معقولة المظهر. الدليل ينصّ على اللوغاريتم الطبيعي.
قراءة ث من عمود السنة الخطأ. الثوابت الثلاثة لكل نطاق هي ٢٠٢٦ و ٢٠٢٧ و ٢٠٢٨ بهذا الترتيب. خلطها يعطي نطاقاً صحيحاً في سنةٍ خاطئة.
«تصحيح» الإشارة السالبة في المقاولات. -0.37 مقصود.
عدّ رؤوس كشف الرواتب بدل الرؤوس المحتسَبة. العقد غير الموثَّق على قوى لا يُحتسب مهما كان الراتب مصروفاً.
عرض النسبة دون النطاق. ٢٢٪ رقمٌ لا معنى له: هو أخضر مريح لمقاول، وأحمر لشركة بنية تحتية تقنية.
تجاهل الكيانات التابعة. الحساب على مستوى الكيان كما يعرّفه ملف المنشأة في قوى، وليس على مستوى الفرع الذي تصادف أنه في قاعدة بياناتك.
الخطوات التالية
المحرّك أعلاه يحسب القاعدة. وما يحوّله إلى نظام حقيقي هو المصدر الذي يغذّيه: مزامنة يومية من قوى للعقود الموثَّقة، ومن التأمينات الاجتماعية للسجلّات المفتوحة، ومن كشف الرواتب لتواريخ نهاية العقود. عندها يصبح ratchetOutlook تقريراً شهرياً للإدارة بدل دالّة في ملف.
- تكامل قوى مع أنظمة الموارد البشرية — كيف تصل إلى بيانات العقود الموثَّقة
- محرّك اشتراكات التأمينات الاجتماعية ومطابقتها — مصدر السجلّات المفتوحة
- مدد وحماية الأجور للمطوّرين — الطرف الآخر من ملف الرواتب
- حاسبة نطاقات — للتحقق اليدوي من أي حالة
الخلاصة
نسبة التوطين ليست رقماً واحداً، بل موقعاً على منحنى يتحرّك تحتك. المحرّك الذي بنيناه يحسب العتبات من ثوابت المرفق رقم ١ بدل جدولٍ ثابت، ويحسب فجوة التوظيف بمقامٍ ينمو، ويفصل الرؤوس المحتسَبة عن رؤوس كشف الرواتب، ويقرأ السنوات القادمة بثوابتها هي.
والمكسب الحقيقي ليس دقّة الحساب، بل الوقت: توظيفان اليوم أرخص من نطاقٍ أحمر بعد أربعة أشهر.
تتابع التوطين على جدول إكسل؟ إن كانت نسبتك تُحسب يدوياً مرةً في الشهر، فأنت تعرف موقعك متأخّراً دائماً — وتكتشف العقود غير الموثَّقة عند أول طلب تأشيرة مرفوض. نربط أنظمة الموارد البشرية بقوى والتأمينات والرواتب حتى تُحتسب المؤشرات تلقائياً وتصل التنبيهات قبل الحافّة لا بعدها. تحدّث إلينا لمراجعة وضع منشأتك.