في 13 أغسطس 2026 أصدرت وزارة الموارد البشرية والتنمية الاجتماعية القرار الوزاري رقم 41454 ومعه تحديث الدليل الإجرائي لمهن إدارة المشاريع. واعتبارًا من 14 فبراير 2027 يجب على كل منشأة في القطاع الخاص يعمل بها ثلاثة عاملين فأكثر في هذه المهن أن يكون 70% منهم سعوديين، محسوبين على مستوى الكيان كله.
معظم أنظمة الموارد البشرية تحمل اليوم قاعدة لهذه المهن: دليل 2023 الصادر بالقرار 141749، الذي يفرض 40% منذ ديسمبر 2024. تعديل النسبة من 40 إلى 70 هو الجزء السهل. فالدليل الجديد يغيّر أيضًا الرموز المهنية التي تُحتسب، ولم يعد ينص على حد أدنى للأجر. أي فحص يكتفي بتعديل النسبة سيحسب أشخاصًا خطأ.
يبني هذا الدرس الفحص بلغة TypeScript انطلاقًا من الدليلين الرسميين، مع اختبارات قابلة للتشغيل. وهو موجّه إلى المطوّر أو مسؤول أنظمة الموارد البشرية الذي عليه ضبط الأرقام قبل فبراير، لا إلى صاحب قرار الامتثال. إن كنت في تلك المرحلة الأسبق، فابدأ من ملخص القرار في الأخبار ومن تقويم مواعيد التوطين في 2027.
المتطلبات المسبقة
- Node.js 20 أو أحدث وTypeScript 5
- ملف تصدير للعاملين فيه لكل عامل: الجنسية، والرمز المهني المسجّل في قوى، والفرع، والكيان الذي ينتمي إليه، والأجر الخاضع للاشتراك في التأمينات الاجتماعية
- عمود إضافي لا تحتويه أغلب ملفات التصدير بعد: هل يمارس الشخص فعلًا عمل إدارة المشاريع. تحصل عليه من المدير المباشر، لا من النظام
- معرفة أساسية بمصطلحات قوى ونطاقات
ما الذي ستبنيه
وحدة برمجية صغيرة تجيب لكل كيان عن خمسة أسئلة:
- كم عاملًا داخل النطاق وفق دليل 2026، وهل ينطبق القرار أصلًا؟
- كم سعوديًا مطلوب، مع طريقة التقريب في الدليل؟
- ما الفجوة إذا استبدلت غير السعوديين، وما هي إذا اكتفيت بالتوظيف؟
- من يدخل النطاق ومن يخرج منه مقارنةً بقواعد 2023 التي يحملها نظامك اليوم؟
- أي العاملين يمثّلون خطرًا لأن رمزهم المهني لا يطابق عملهم الفعلي؟
والناتج خطة عمل يقرؤها فريق الموارد البشرية، مع عدد الأيام المتبقية من فترة السماح.
الخطوة 1: ماذا يقول الدليل فعلًا
كل ما يلي مأخوذ من وثيقتين منشورتين على موقع الوزارة: خطاب القرار رقم 41454، والدليل الإجرائي المرفق به في ثماني صفحات. وينص القرار على أن الدليل جزء لا يتجزأ منه.
النسبة. 70% من إجمالي العاملين في المهن المستهدفة على مستوى الكيان (الدليل، البند 3.1).
النطاق. جميع منشآت القطاع الخاص العاملة في السوق السعودي التي يعمل بها 3 عاملين فأكثر في مهن الجدول رقم 1 (البند 3.2). المنشأة التي لديها عاملان خارج القرار.
الكيان. يعرّفه الدليل بأنه الصفة التي تتعامل معها الوزارة لأغراض حساب التوطين، ويمثّل جميع الفروع ذات النشاط الاقتصادي المملوكة لمنشأة واحدة. مديرو المشاريع الثلاثة لديك في الرياض وجدة والدمام مجموعة واحدة من ثلاثة، لا ثلاث مجموعات من واحد.
المهن. يسرد الجدول رقم 1 ثلاثة رموز بالضبط من التصنيف السعودي الموحد للمهن:
| الرمز | المهنة |
|---|---|
| 121314 | مدير إدارة مشاريع |
| 214909 | مهندس إدارة مشاريع |
| 242108 | أخصائي إدارة مشاريع |
التقريب. المثال المحلول في الصفحة 5 يأخذ خمسة عاملين: 70% من 5 تساوي 3.5، و"التقريب لأقرب عدد صحيح" يعطي أربعة سعوديين. ومع وجود سعوديَّين، على المنشأة استبدال عاملَين غير سعوديَّين. وتضيف حاشية أنه في حال إضافة سعوديين بدل الإحلال يُعاد الاحتساب على العدد الجديد.
التداخل. البند الثالث من القرار والبند 3.2 من الدليل يقولان الشيء نفسه: إذا استهدف قرار آخر المهنة نفسها بنسبة مختلفة، تُطبَّق النسبة الأعلى.
التواريخ. صدر القرار في 13 أغسطس 2026، مع فترة سماح ستة أشهر، والتطبيق اعتبارًا من 14 فبراير 2027 (البند 3.5). وتؤكد الأسئلة الشائعة أنه يمكن الاستفادة من خدمات الوزارة خلال فترة السماح حتى لو لم تتحقق النسبة.
العقوبات. يطبّق البند 5 جدول المخالفات والعقوبات الصادر بالقرار الوزاري رقم 112377 وتاريخ 21/8/1447هـ. ويسمّي مخالفة محددة: إسناد أي من مهام هذه المهن إلى عامل غير سعودي، بطريقة مباشرة أو غير مباشرة، تحت أي مسمى وظيفي آخر.
وتضيف الأسئلة الشائعة ثلاث نقاط تهمّ الكود:
- يُطبَّق القرار بغض النظر عن نطاق المنشأة في نطاقات. النطاق الأخضر لا يعفيك.
- يُطبَّق على المسميات المهنية في الجدول رقم 1 وعلى العمل الفعلي للعامل.
- يُحتسب الالتزام آليًا بناءً على المسميات المهنية في قاعدة بيانات التأمينات الاجتماعية وبيانات الجهات الإشرافية.
ما الذي تغيّر عن دليل 2023
دليل 2023 الصادر بالقرار 141749 وتاريخ 11/09/1444هـ حدّد 35% ثم 40%، ويختلف في أمرين أهم من النسبة:
- سبعة رموز لا ثلاثة. كان يضم 121314 و242108، ومعهما خمسة رموز قديمة من سبعة أرقام: 2133241 مدير مشاريع، و2133231 أخصائي مكتب إدارة المشاريع، و1216031 مدير مشروع اتصالات، و1217011 مدير مشروع لخدمات الأعمال، و1216011 مدير مشروع نقل. جدول 2026 يحذف هذه الخمسة ويضيف 214909 مهندس إدارة مشاريع.
- حد أدنى للأجر. نص البند 1.2 من دليل 2023 على أن السعودي لا يُحتسب إلا إذا كان أجره الخاضع للاشتراك في التأمينات الاجتماعية 6,000 ريال على الأقل. ولا يرد هذا الشرط في أي من صفحات دليل 2026 الثماني.
القرار لا يقول صراحةً إن الحد الأدنى أُلغي. فالبند السادس يلغي ما يتعارض معه، والبند الثالث يُبقي القرارات السابقة سارية فيما تتوافق فيه. قراءتنا أن الحد الأدنى سقط، لأن الدليل الذي يحدد قواعد الاحتساب لم يعد يتضمنه. اطلب تأكيدًا من الوزارة أو من مدير حسابك في قوى قبل أن تحتسب سعوديًا يقل أجره عن 6,000 ريال. الكود أدناه يُبقي الحد الأدنى إعدادًا يمكنك إعادة تفعيله بسطر واحد.
الخطوة 2: القواعد كبيانات
احفظ كل دليل كسجلّ، لا كثوابت موزعة في الكود. عندما تنشر الوزارة التحديث التالي، تضيف سجلًا وتخبرك الاختبارات بما تغيّر.
// src/rules.ts
export type OccupationCode = string;
export interface QuotaRule {
decision: string;
guideIssued: string; // ISO date
effectiveFrom: string; // ISO date
ratePercent: number; // whole percent, e.g. 70
minWorkers: number; // the rule applies from this many workers in scope
codes: Record<OccupationCode, string>;
gosiWageFloor: number | null; // SAR per month, null = no floor in the guide
}
// Procedural guide issued with Ministerial Decision 41454 (13 Aug 2026)
export const PM_2026: QuotaRule = {
decision: "41454",
guideIssued: "2026-08-13",
effectiveFrom: "2027-02-14",
ratePercent: 70,
minWorkers: 3,
codes: {
"121314": "Project management manager",
"214909": "Project management engineer",
"242108": "Project management specialist",
},
gosiWageFloor: null,
};
// The 2023 guide for Ministerial Decision 141749, second phase (40%),
// in force since 11/06/1446 AH (about 12 Dec 2024)
export const PM_2023: QuotaRule = {
decision: "141749",
guideIssued: "2023-04-02",
effectiveFrom: "2024-12-12",
ratePercent: 40,
minWorkers: 3,
codes: {
"121314": "Project management manager",
"242108": "Project management specialist",
"2133241": "Project manager",
"2133231": "Project management office specialist",
"1216031": "Telecommunications project manager",
"1217011": "Business services project manager",
"1216011": "Transport project manager",
},
gosiWageFloor: 6000,
};النسبة رقم صحيح بالنسبة المئوية عن قصد، والخطوة 3 تشرح السبب.
الخطوة 3: التقريب دون أعداد عشرية
في JavaScript يعطي 45 * 0.7 القيمة 31.499999999999996 لا 31.5. فتُرجع Math.round العدد 31 حيث تعطي قاعدة الدليل 32: سعودي واحد ناقص، مع تقرير يقول إنك ممتثل. ويتكرر ذلك عند 85 و165 و175 عاملًا. العمل بالنسب المئوية الصحيحة يتجنب المشكلة: اضرب عدد العاملين في 70، وأضف 50، واقسم على 100 وأسقط الباقي. هذا هو التقريب لأقرب عدد صحيح، مع رفع النصف إلى الأعلى كما في مثال الدليل.
// src/quota.ts
// Saudis required for a headcount, rounded to the nearest whole number
// (3.5 becomes 4, as in the guide's worked example).
// Integer arithmetic only: 45 * 0.7 is 31.499999999999996 in floating point.
export function requiredSaudis(headcount: number, ratePercent: number): number {
return Math.floor((headcount * ratePercent + 50) / 100);
}
// Replacement: swap non-Saudis for Saudis, headcount stays the same.
export function gapByReplacement(
headcount: number,
saudis: number,
ratePercent: number,
): number {
return Math.max(0, requiredSaudis(headcount, ratePercent) - saudis);
}
// Hiring only: each new Saudi raises both sides of the ratio.
export function gapByHiring(
headcount: number,
saudis: number,
ratePercent: number,
): number {
if (ratePercent >= 100) {
return saudis >= headcount ? 0 : Number.POSITIVE_INFINITY;
}
let hires = 0;
while (saudis + hires < requiredSaudis(headcount + hires, ratePercent)) {
hires += 1;
}
return hires;
}دالتا الفجوة تجيبان عن سؤالين مختلفين. الإحلال يُبقي عدد العاملين ثابتًا: تستبدل غير سعودي بسعودي فيتحرك البسط وحده. التوظيف يضيف أشخاصًا: كل سعودي جديد يرفع البسط والمقام معًا، ولهذا تقول حاشية الدليل بإعادة الاحتساب.
الفرق كبير عند 70%. في مثال الدليل نفسه، خمسة عاملين منهم سعوديان، يحتاج الإحلال إلى شخصين ويحتاج التوظيف وحده إلى أربعة. ومع عشرة عاملين منهم ثلاثة سعوديين، يحتاج الإحلال إلى أربعة ويحتاج التوظيف وحده إلى اثني عشر. تتوقف الحلقة لأن نسبة السعوديين ترتفع مع كل تعيين عند أي نسبة أقل من 100%. أما الحماية الخاصة بنسبة 100% فوجودها ليصلح الكود نفسه للقرارات التي تقصر مهنة على السعوديين بالكامل.
الخطوة 4: النطاق هو الرمز والعمل معًا
تنص الأسئلة الشائعة صراحةً على أن القرار يُطبَّق على المسمى المصنَّف وعلى العمل الفعلي. هذا يعطي حالتي فشل مختلفتين، والوحدة تسجّل الاثنتين:
- عامل يحمل أحد الرموز الثلاثة ولا يمارس إدارة المشاريع. نُخرجه من النطاق. وإذا اختلف العمل عن الرمز في هذا الاتجاه، فالإصلاح الصحيح هو تصحيح التسجيل، وينبغي أن يُظهر التقرير ذلك بدل أن يحتسب الشخص.
- عامل يمارس إدارة المشاريع تحت رمز آخر. إن كان غير سعودي، يعدّ البند 5 ذلك مخالفة قائمة بذاتها. وإن كان سعوديًا، فهو احتساب ضائع: لا يرفع نسبتك إلا بعد تسجيل الرمز الصحيح.
// src/assess.ts
import { QuotaRule } from "./rules";
import { requiredSaudis, gapByReplacement, gapByHiring } from "./quota";
export interface Worker {
id: string;
entityId: string; // all branches with the same activity, one owner
branchId: string;
saudi: boolean;
occupationCode: string; // as registered in Qiwa / GOSI
doesPmWork: boolean; // what the person actually does, from the line manager
gosiWage: number; // contributory wage, SAR per month
}
export type Finding =
| { kind: "title-mismatch"; workerId: string; saudi: boolean }
| { kind: "below-wage-floor"; workerId: string; wage: number };
export interface EntityResult {
entityId: string;
inScope: number;
saudisCounted: number;
applies: boolean;
required: number;
rate: number; // percent, one decimal
byReplacement: number;
byHiring: number;
findings: Finding[];
}
export function assessEntity(
entityId: string,
workers: Worker[],
rule: QuotaRule,
): EntityResult {
const mine = workers.filter((w) => w.entityId === entityId);
const findings: Finding[] = [];
// Duties without the code. For a non-Saudi the guide calls this a breach;
// a Saudi in the same position is simply not counted.
for (const w of mine) {
if (w.doesPmWork && !(w.occupationCode in rule.codes)) {
findings.push({ kind: "title-mismatch", workerId: w.id, saudi: w.saudi });
}
}
// Scope is the registered code AND the actual work.
const scoped = mine.filter((w) => w.occupationCode in rule.codes && w.doesPmWork);
const counted = scoped.filter((w) => {
if (!w.saudi) return false;
if (rule.gosiWageFloor !== null && w.gosiWage < rule.gosiWageFloor) {
findings.push({ kind: "below-wage-floor", workerId: w.id, wage: w.gosiWage });
return false;
}
return true;
});
const n = scoped.length;
const s = counted.length;
const applies = n >= rule.minWorkers;
return {
entityId,
inScope: n,
saudisCounted: s,
applies,
required: applies ? requiredSaudis(n, rule.ratePercent) : 0,
rate: n === 0 ? 0 : Math.round((s / n) * 1000) / 10,
byReplacement: applies ? gapByReplacement(n, s, rule.ratePercent) : 0,
byHiring: applies ? gapByHiring(n, s, rule.ratePercent) : 0,
findings,
};
}ثلاث تفاصيل في هذه الدالة مأخوذة مباشرة من الدليل:
- يُصفّى العاملون حسب
entityIdلا حسب الفرع، فيُحسب حد الثلاثة عاملين ونسبة 70% عبر الفروع كلها. - اختبار الحد هو
n >= rule.minWorkers. عاملان خارج النطاق، وثلاثة داخله. - لا يعمل الحد الأدنى للأجر إلا إذا كانت القاعدة تتضمنه. في
PM_2026قيمتهnull، وفيPM_2023يُستبعد السعودي الذي يقل أجره عن 6,000 ريال ويُذكر في التقرير.
تُعرض النسبة بخانة عشرية واحدة، لكن الامتثال لا يستخدمها أبدًا. الامتثال يقارن أعدادًا، لأن الدليل يتعامل مع أشخاص كاملين.
الخطوة 5: حين يغطي قراران رمزًا واحدًا
للمهندسين قرار توطين خاص بهم، والرمز 214909 يقع في مجموعة المهن الهندسية من التصنيف. أما هل يُدرج قرار هندسي بعينه الرمز 214909، فذلك يُتحقق منه في دليله. الدالة أدناه لا تخمّن؛ إنها تطبّق القاعدة التي ينص عليها الدليلان: من بين القرارات السارية في تاريخ معيّن، تفوز النسبة الأعلى.
// src/overlap.ts
import { QuotaRule } from "./rules";
// When two decisions cover the same code, the higher rate applies.
// Only decisions already in force on the date are considered.
export function ruleFor(
code: string,
onDate: string,
rules: QuotaRule[],
): QuotaRule | undefined {
return rules
.filter((r) => code in r.codes && r.effectiveFrom <= onDate)
.sort((a, b) => b.ratePercent - a.ratePercent)[0];
}وهي تعالج المرحلة الانتقالية أيضًا. حتى 13 فبراير 2027 تبقى قاعدة 2023 بنسبة 40% هي السارية. ومن 14 فبراير تحلّ قاعدة 2026 بنسبة 70% محلها للرموز التي تغطيها. مرّر لها كل القرارات التي تتابعها، فتُرجع القرار الذي يحكم كل عامل في التاريخ الذي تسأل عنه.
الخطوة 6: قارن قائمتي 2023 و2026
هذه الخطوة التي ستتجاوزها أغلب الفرق، وهي التي تكشف الأخطاء المكلفة. شغّل القاعدة القديمة والجديدة على القوى العاملة نفسها، واسرد من ينتقل.
// src/diff.ts
import { QuotaRule } from "./rules";
import { Worker } from "./assess";
export interface RuleDiff {
codesAdded: string[];
codesRemoved: string[];
workersJoining: string[]; // in scope under the new rule only
workersLeaving: string[]; // in scope under the old rule only
wageFloorChange: [number | null, number | null];
}
export function diffRules(
oldRule: QuotaRule,
newRule: QuotaRule,
workers: Worker[],
): RuleDiff {
const oldCodes = Object.keys(oldRule.codes);
const newCodes = Object.keys(newRule.codes);
const inOld = (w: Worker) => w.occupationCode in oldRule.codes;
const inNew = (w: Worker) => w.occupationCode in newRule.codes;
return {
codesAdded: newCodes.filter((c) => !oldCodes.includes(c)),
codesRemoved: oldCodes.filter((c) => !newCodes.includes(c)),
workersJoining: workers.filter((w) => inNew(w) && !inOld(w)).map((w) => w.id),
workersLeaving: workers.filter((w) => inOld(w) && !inNew(w)).map((w) => w.id),
wageFloorChange: [oldRule.gosiWageFloor, newRule.gosiWageFloor],
};
}ما الذي تتوقعه عند تشغيلها على ملف تصدير حقيقي:
- الداخلون: كل من سُجّل برمز 214909 مهندس إدارة مشاريع. كانوا خارج قاعدة 40%، وأصبحوا الآن في المقام، وإن لم يكونوا سعوديين فهم يرفعون العدد المطلوب منك.
- الخارجون: كل من لا يزال على 2133241 أو 2133231 أو 1216031 أو 1217011 أو 1216011. هذه الرموز ذات الأرقام السبعة تنتمي إلى التصنيف الأقدم. قبل أن تفرح بمقام أصغر، تحقق مما يحمله نظام قوى فعلًا لهؤلاء اليوم. إن نُقلت رموزهم إلى رمز حالي من ستة أرقام، فقد يكونون أصلًا ضمن الرموز الثلاثة.
- الحد الأدنى للأجر: 6,000 ريال سابقًا، ولا شيء في دليل 2026. السعوديون الذين استبعدتهم لانخفاض الأجر قد يُحتسبون الآن، بشرط التأكيد المذكور في الخطوة 1.
الخطوة 7: من النتائج إلى خطة عمل
فريق الموارد البشرية لا يقرأ الكائنات البرمجية. حوّل النتيجة إلى أسطر يمكن العمل بها، مع حساب فترة السماح بالأيام.
// src/plan.ts
import { EntityResult } from "./assess";
export function phaseOn(today: string, effectiveFrom: string): "grace" | "enforced" {
return today < effectiveFrom ? "grace" : "enforced";
}
export function daysLeft(today: string, effectiveFrom: string): number {
const ms = Date.parse(effectiveFrom) - Date.parse(today);
return Math.max(0, Math.ceil(ms / 86_400_000));
}
export function actionPlan(r: EntityResult, today: string, effectiveFrom: string): string[] {
const lines: string[] = [];
if (!r.applies) {
lines.push(`Entity ${r.entityId}: ${r.inScope} in scope, below the threshold. Re-run on every hire.`);
return lines;
}
lines.push(
`Entity ${r.entityId}: ${r.saudisCounted}/${r.inScope} Saudi (${r.rate}%), ` +
`${r.required} required.`,
);
if (r.byReplacement > 0) {
lines.push(`Replace ${r.byReplacement}, or hire ${r.byHiring} additional Saudis.`);
const phase = phaseOn(today, effectiveFrom);
lines.push(
phase === "grace"
? `${daysLeft(today, effectiveFrom)} days of grace left.`
: "Grace period over: the entity is in breach now.",
);
}
for (const f of r.findings) {
if (f.kind === "title-mismatch" && f.saudi) {
lines.push(`Worker ${f.workerId} is Saudi and not counted: register the right code in Qiwa.`);
} else if (f.kind === "title-mismatch") {
lines.push(`Worker ${f.workerId} does project management under another code: fix the role or the code.`);
} else {
lines.push(`Worker ${f.workerId} is below the wage floor (SAR ${f.wage}).`);
}
}
return lines;
}اختبار التنفيذ
تثبّت هذه الاختبارات كل رقم يذكره الدليل، والحالات الحدّية حوله. شغّلها بالأمر node --import tsx --test src/assess.test.ts.
// src/assess.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { PM_2023, PM_2026 } from "./rules";
import { requiredSaudis, gapByReplacement, gapByHiring } from "./quota";
import { assessEntity, Worker } from "./assess";
import { ruleFor } from "./overlap";
import { diffRules } from "./diff";
import { actionPlan, daysLeft } from "./plan";
const w = (id: string, saudi: boolean, code = "121314", extra: Partial<Worker> = {}): Worker => ({
id, entityId: "E1", branchId: "B1", saudi, occupationCode: code,
doesPmWork: true, gosiWage: 9000, ...extra,
});
test("guide example: 5 workers at 70% need 4 Saudis", () => {
assert.equal(requiredSaudis(5, 70), 4);
assert.equal(gapByReplacement(5, 2, 70), 2);
assert.equal(gapByReplacement(5, 5, 70), 0);
});
test("rounding is to the nearest whole number", () => {
assert.equal(requiredSaudis(3, 70), 2); // 2.1
assert.equal(requiredSaudis(4, 70), 3); // 2.8
assert.equal(requiredSaudis(15, 70), 11); // 10.5
assert.equal(requiredSaudis(20, 70), 14);
assert.equal(requiredSaudis(45, 70), 32); // 45 * 0.7 is 31.499999999999996
});
test("hiring only needs more people than replacement", () => {
assert.equal(gapByHiring(5, 2, 70), 4);
assert.equal(gapByHiring(10, 3, 70), 12);
assert.equal(gapByHiring(10, 7, 70), 0);
assert.equal(gapByHiring(3, 1, 100), Number.POSITIVE_INFINITY);
});
test("below three workers in scope the decision does not apply", () => {
const r = assessEntity("E1", [w("a", false), w("b", false)], PM_2026);
assert.equal(r.applies, false);
assert.equal(r.byReplacement, 0);
});
test("branches of one entity are counted together", () => {
const ws = [w("a", true), w("b", false, "214909", { branchId: "B2" }), w("c", false, "242108", { branchId: "B3" })];
const r = assessEntity("E1", ws, PM_2026);
assert.equal(r.inScope, 3);
assert.equal(r.required, 2);
assert.equal(r.byReplacement, 1);
});
test("a code without the work is out of scope; work without the code is flagged", () => {
const ws = [
w("a", true), w("b", true), w("c", false),
w("d", false, "121314", { doesPmWork: false }),
w("e", false, "263101"),
w("f", true, "263101"),
];
const r = assessEntity("E1", ws, PM_2026);
assert.equal(r.inScope, 3);
assert.equal(r.saudisCounted, 2);
assert.deepEqual(r.findings, [
{ kind: "title-mismatch", workerId: "e", saudi: false },
{ kind: "title-mismatch", workerId: "f", saudi: true },
]);
});
test("the 2026 guide sets no wage floor; the 2023 one did", () => {
const ws = [w("a", true, "121314", { gosiWage: 5000 }), w("b", true), w("c", false)];
assert.equal(assessEntity("E1", ws, PM_2026).saudisCounted, 2);
const old = assessEntity("E1", ws, PM_2023);
assert.equal(old.saudisCounted, 1);
assert.equal(old.findings[0].kind, "below-wage-floor");
});
test("diff between the 2023 and 2026 lists", () => {
const ws = [w("a", true, "214909"), w("b", false, "2133241"), w("c", false, "121314")];
const d = diffRules(PM_2023, PM_2026, ws);
assert.deepEqual(d.codesAdded, ["214909"]);
assert.equal(d.codesRemoved.length, 5);
assert.deepEqual(d.workersJoining, ["a"]);
assert.deepEqual(d.workersLeaving, ["b"]);
assert.deepEqual(d.wageFloorChange, [6000, null]);
});
test("higher rate wins only once in force", () => {
assert.equal(ruleFor("121314", "2026-12-01", [PM_2023, PM_2026])?.decision, "141749");
assert.equal(ruleFor("121314", "2027-02-14", [PM_2023, PM_2026])?.decision, "41454");
assert.equal(ruleFor("999999", "2027-02-14", [PM_2026]), undefined);
});
test("action plan counts grace days", () => {
assert.equal(daysLeft("2026-11-16", "2027-02-14"), 90);
const r = assessEntity("E1", [w("a", true), w("b", false), w("c", false)], PM_2026);
const plan = actionPlan(r, "2026-11-16", PM_2026.effectiveFrom);
assert.match(plan[1], /Replace 1, or hire 3/);
assert.match(plan[2], /90 days/);
const late = actionPlan(r, "2027-02-14", PM_2026.effectiveFrom);
assert.match(late[2], /in breach now/);
});عشرة اختبارات، كلها ناجحة على Node 20 وما بعده، وtsc --strict دون أخطاء. وقد غيّرنا الكود عمدًا أيضًا (التقريب بـ 49 بدل 50، وMath.round على نسبة عشرية، وحدّ صارم، وترتيب معكوس، وحذف شرط العمل الفعلي، وتجاهل تاريخ السريان، وحذف حماية نسبة 100%) وتحققنا من أن اختبارًا واحدًا على الأقل يفشل مع كل تغيير.
فحص واحد لا تستطيعه الاختبارات: مقارنة نتيجة الوحدة بما يعرضه قوى للكيان نفسه. الوزارة تحسب من بيانات التأمينات الاجتماعية. وإذا اختلف الرقمان، فالسبب في الغالب هو البيانات: رمز مسجّل في التأمينات بخلاف نظام الموارد البشرية لديك، أو فرع ملحق بكيان آخر.
حل المشكلات
قوى يعرض عددًا مختلفًا عن ملف التصدير. قارن تجميع الكيانات أولًا. الفروع ذات النشاط الاقتصادي المختلف قد تكون كيانًا منفصلًا، والفرع الذي تعامله منفصلًا قد يكون مجمّعًا مع المركز الرئيسي.
سعودي لا يُحتسب وأجره أعلى من 6,000. تحقق من الرمز المسجّل. مدير المشاريع السعودي المسجّل برمز إداري عام لا يُحتسب، مهما كان عمله الحقيقي. ونتيجة title-mismatch مع saudi: true هي هذه الحالة.
الأرقام تقفز في 14 فبراير. هذا انتقال الدالة من قاعدة 40% إلى قاعدة 70%. شغّل التقييم للتاريخين خلال فترة السماح حتى تكون القفزة مخططًا لها.
تتابع عدة قرارات والأعداد تبدو مضاعفة. يجب تقييم كل عامل وفق قاعدة واحدة في تاريخ معيّن. استخدم ruleFor لتحديد القاعدة لكل عامل، ثم جمّع حسب القاعدة قبل استدعاء assessEntity.
خمسة عاملين، ثلاثة منهم سعوديون، والوحدة تقول إنك ناقص. 70% من 5 تساوي 3.5، وتُقرَّب إلى 4. ثلاثة لا تكفي. وهذه هي الحالة التي يستخدمها الدليل مثالًا.
الخطوات التالية
- طبّق النمط نفسه على المهن الأخرى في كشف رواتبك. محرك توطين الفنيين الهندسيين يستخدم نهج القواعد كبيانات نفسه، ومحرك التوطين العام يغطي نسبة نطاقات على مستوى المنشأة، وهي منفصلة عن هذا القرار وتُطبَّق في الوقت نفسه.
- لتجربة أرقام الإحلال والتوظيف يدويًا، تقبل حاسبة نطاقات المجانية نسبة مطلوبة: أدخل 70 مع عدد العاملين في إدارة المشاريع وحدهم.
- اقرأ جدول مخالفات القرار 112377 لتعرف كلفة المخالفة قبل أن تسعّر التعيينات.
- اربط الوحدة ببيانات حية بدل تصدير شهري. يشرح دليل ربط قوى بأنظمة الموارد البشرية مصدر كل حقل.
الخلاصة
رقم 70% هو العنوان، لكنه ليس المكان الذي ستخطئ فيه الأنظمة. ستخطئ في قائمة الرموز (ثلاثة رموز الآن، منها 214909، لا السبعة من 2023)، وفي الحد الأدنى للأجر الذي لم يعد دليل 2026 ينص عليه، وفي الاحتساب حسب الفرع بدل الكيان، وفي العاملين الذين لا يطابق مسماهم عملهم الحقيقي. كل واحدة من هذه بضعة أسطر من الكود واختبار واحد، والمقارنة في الخطوة 6 تريك، على بياناتك أنت، أيها يمسّك.
بقي لك ثلاثة أشهر من فترة السماح. وهي كافية لتصحيح التسجيلات في قوى، وهو أمر لا يكلف شيئًا، قبل أن تقرر كم شخصًا ستوظف، وهو أمر يكلف كثيرًا.
إذا أردت ربط هذا الفحص بنظام الموارد البشرية لديك وتشغيله قبل كل تعيين، احجز جلسة تشخيص مجانية وسنراجع بياناتك معك وفق دليل 2026.