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

توطين المهن المحاسبية بـ TypeScript: محرّك نسبة الـ 50%

ابنِ محرّك توطين المهن المحاسبية بلغة TypeScript قبل 27 أكتوبر 2026: المراحل الخمس، ورموز المهن الاثنان والعشرون، والشروط الثلاثة التي يجب أن تتحقق معاً حتى يُحتسب المحاسب السعودي، والتقريب الصحيح الذي يخطئ فيه العدد العشري برأس كامل، ولماذا لا يسدّ توظيف ثلاثة سعوديين فجوة قدرها ثلاثة.

في 27 أكتوبر 2026 ترتفع نسبة توطين المهن المحاسبية من 40% إلى 50%. تلك هي المرحلة الثانية من القرار الوزاري 103108، وهي المرحلة التي توقع المنشآت، لأن المنشأة لا تحتاج إلى أن يتغيّر فيها شيء كي تخرج من الالتزام. المنشأة التي تجاوزت الـ 40% برأس واحد في أكتوبر الماضي تصبح مخالِفة في أكتوبر القادم دون أن توظّف أحداً، ولا تُنهِ خدمة أحد، ولا تغيّر شيئاً.

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

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

  • Node.js 20 أو أحدث و TypeScript 5.x
  • إلمام بالأنواع الاتحادية المميّزة والأنواع الحرفية
  • صلاحية قراءة على مصدرَي بيانات لا يُدمجان عادةً: المسميات الوظيفية المسجّلة لدى التأمينات الاجتماعية، وحالة الاعتماد المهني من الهيئة السعودية للمحاسبين القانونيين
  • قراءة للقرار نفسه، ملخّصة في توطين المهن المحاسبية يبلغ 50% في 27 أكتوبر 2026

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

وحدة برمجية نقية بلا اعتماديات، تجيب عن أربعة أسئلة في أي تاريخ:

  1. هل المنشأة داخلة في نطاق القرار أصلاً؟
  2. ما النسبة السارية في ذلك التاريخ؟
  3. أيٌّ من محاسبيّ السعوديين يُحتسب فعلاً، ومَن استُبعد ولماذا؟
  4. كم شخصاً يجب أن يتغيّر، وهل يسدّ التوظيف الفجوة أم يوسّعها؟

كل ثابت في ما يلي مأخوذ من الدليل الإجرائي للوزارة. وكل رقم في الأمثلة المحلولة نُفِّذ فعلياً قبل أن يُكتب.

الخطوة 1: لماذا نموذج نطاقات هو الشكل الخاطئ

يسأل نطاقات سؤالاً واحداً عن المنشأة كاملة: ما نسبة السعوديين من الجميع، مقيسة على منحنى يتحرك مع حجمك ونشاطك. أما القرار 103108 فيسأل سؤالاً أضيق وأشدّ.

ثلاثة فروق بنيوية تحكم التصميم كلّه:

  • المقام هو المهن المحاسبية وحدها. شركة فيها 400 موظف من بينهم 23 محاسباً تقسم على 23 لا على 400.
  • النسبة ثابتة ومتدرّجة بالتاريخ، وليست مشتقّة من منحنى. وهي عدد صحيح من النقاط المئوية، وهذا أهمّ مما يبدو.
  • الالتزام موازٍ. يسري على مستوى الكيان بغضّ النظر عن نطاق المنشأة، ويتراكم مع توطين مهن إدارة المشاريع بنسبة 70% اعتباراً من 14 فبراير 2027. ويمكن للمنشأة أن تكون في الأخضر وتخالف القرارين معاً.

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

الخطوة 2: جدول المراحل، وانعكاس المرحلة الخامسة

معظم الملخّصات المنشورة تصوّر الجدول سلّماً صاعداً — 40 ثم 50 ثم 60 ثم 70. والمرحلة الخامسة ليست درجة في ذلك السلّم. إنها تفعل أمرين في آن: تُبقي 70% للمنشآت التي بها خمسة محاسبين فأكثر، وتُدخل المنشآت التي بها ثلاثة أو أربعة محاسبين في النطاق لأول مرة بنسبة 30%.

إن مثّلت المرحلة بنسبة واحدة، صارت المرحلة الخامسة غير قابلة للتمثيل. مثّلها بمجموعة شرائح:

// tawteen/phases.ts
 
/** A size bracket within a phase. `max: null` means "and above". */
export type Bracket = {
  readonly min: number;
  readonly max: number | null;
  /** Whole percentage points. Never store this as a fraction. */
  readonly ratePercent: number;
};
 
export type Phase = {
  readonly ordinal: 1 | 2 | 3 | 4 | 5;
  /** ISO date the phase takes effect, inclusive. */
  readonly effectiveFrom: string;
  readonly brackets: readonly Bracket[];
};
 
export const ACCOUNTING_PHASES: readonly Phase[] = [
  { ordinal: 1, effectiveFrom: '2025-10-27', brackets: [{ min: 5, max: null, ratePercent: 40 }] },
  { ordinal: 2, effectiveFrom: '2026-10-27', brackets: [{ min: 5, max: null, ratePercent: 50 }] },
  { ordinal: 3, effectiveFrom: '2027-10-27', brackets: [{ min: 5, max: null, ratePercent: 60 }] },
  { ordinal: 4, effectiveFrom: '2028-10-27', brackets: [{ min: 5, max: null, ratePercent: 70 }] },
  {
    ordinal: 5,
    effectiveFrom: '2029-10-27',
    brackets: [
      // New in phase five: small establishments enter scope.
      { min: 3, max: 4, ratePercent: 30 },
      { min: 5, max: null, ratePercent: 70 },
    ],
  },
];

الاستدلال هنا بحث لا حساب. أعِد null للخروج من النطاق بدل الصفر — فالصفر نسبة، والمنشأة تحت الحدّ لا نسبة لها أصلاً:

// tawteen/rate.ts
import { ACCOUNTING_PHASES, type Phase } from './phases';
 
export type ApplicableRate = {
  readonly phase: Phase['ordinal'];
  readonly ratePercent: number;
};
 
function phaseOn(onDate: string): Phase | null {
  let found: Phase | null = null;
  // ISO strings compare lexicographically; the table is in ascending order.
  for (const p of ACCOUNTING_PHASES) if (p.effectiveFrom <= onDate) found = p;
  return found;
}
 
/** `null` means the establishment is outside the decision's scope on that date. */
export function rateFor(totalAccountants: number, onDate: string): ApplicableRate | null {
  const phase = phaseOn(onDate);
  if (!phase) return null;
 
  for (const b of phase.brackets) {
    if (totalAccountants < b.min) continue;
    if (b.max !== null && totalAccountants > b.max) continue;
    return { phase: phase.ordinal, ratePercent: b.ratePercent };
  }
  return null;
}

المنشأة التي بها أربعة محاسبين تحصل على null في 27 أكتوبر 2026، وعلى phase 5, 30% في 27 أكتوبر 2029. وهذا الانتقال وحده يستحق تنبيهاً مستقلاً، لأنه يصل دون أي تغيّر من جانب المنشأة.

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

الخطوة 3: رموز المهن هي المقام

يسمّي القرار رموز مهن من التصنيف السعودي الموحّد للمهن، والقائمة أوسع مما توحي به كلمة «محاسب». فمراقب المخزون ومساعد المحاسب من بينها. وكل رأس من هؤلاء يجلس في مقامك سواء اعتبره نظام الموارد البشرية من الكادر المالي أم لا.

// tawteen/job-codes.ts
 
/**
 * Job codes targeted by Ministerial Decision 103108, as transcribed from the
 * ministry's procedural guide. This table IS the denominator: a worker whose
 * GOSI job code is absent from it is outside the calculation entirely.
 */
export const TARGETED_JOB_CODES = {
  '121101': 'Financial manager',
  '121102': 'Accounts manager',
  '121103': 'Tariff accounts manager',
  '121104': 'Treasury manager',
  '121105': 'Budget manager',
  '121106': 'Audit manager',
  '121107': 'Internal audit manager',
  '121113': 'Collections manager',
  '121116': 'Treasury director',
  '241101': 'Accountant',
  '241102': 'Cost accountant',
  '241103': 'Internal auditor',
  '241105': 'Chartered accountant',
  '241106': 'Financial controller',
  '241107': 'Financial budgeting specialist',
  '241108': 'Tax accounts specialist',
  '241109': 'Inventory control specialist',
  '331301': 'Accounts assistant',
  '331302': 'Inventory controller',
  '335202': 'Tax officer',
  '431101': 'Accounts clerk',
  '431201': 'Finance clerk',
} as const;
 
export type TargetedJobCode = keyof typeof TARGETED_JOB_CODES;
 
export function isTargeted(code: string): code is TargetedJobCode {
  return code in TARGETED_JOB_CODES;
}

لا تكتب عدد الرموز كقيمة ثابتة في أي مكان. فالملخّصات المنشورة لهذا القرار تختلف فيما إذا كان الجدول يضمّ 21 أو 22 مدخلاً. استخرج العدد من الجدول نفسه، وثبّته باختبار في مقابل الدليل الرسمي، حتى يُسقِط أي خطأ نسخٍ عمليةَ البناء بدل أن يقلّص مقامك بصمت.

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

الخطوة 4: ثلاثة أنظمة يجب أن تتفق

هنا يختلف هذا القرار عن كل نسبة رؤوس كتبتها من قبل. لا يكفي أن يكون الموظف سعودياً ويعمل في المحاسبة. فثلاثة شروط يجب أن تتحقق في آن واحد، وهي تسكن ثلاثة أنظمة مختلفة:

  1. رمز المهنة المسجّل لدى التأمينات الاجتماعية ضمن الجدول.
  2. الأجر الخاضع للاشتراك لا يقلّ عن 6,000 ريال لحملة البكالوريوس أو ما يعادله، أو 4,500 ريال لحملة الدبلوم أو ما يعادله.
  3. الاعتماد المهني من الهيئة السعودية للمحاسبين القانونيين فعّال.

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

// tawteen/countable.ts
import { isTargeted } from './job-codes';
 
export type Qualification = 'BACHELOR' | 'DIPLOMA';
 
/** Minimum GOSI contributory wage in SAR for a Saudi accountant to be counted. */
export const WAGE_FLOOR_SAR: Record<Qualification, number> = {
  BACHELOR: 6000,
  DIPLOMA: 4500,
};
 
export type ExclusionReason =
  | 'JOB_CODE_NOT_TARGETED'
  | 'WAGE_BELOW_FLOOR'
  | 'SOCPA_ACCREDITATION_INACTIVE'
  | 'QUALIFICATION_UNMAPPED';
 
export type Employee = {
  readonly id: string;
  readonly nationality: 'SA' | 'NON_SA';
  /** The code as registered with GOSI — not the internal HR title. */
  readonly gosiJobCode: string;
  /** Undefined when HR holds no usable qualification record. */
  readonly qualification?: Qualification;
  /** The GOSI contributory wage, not gross pay and not basic salary. */
  readonly contributoryWageSar: number;
  readonly socpaAccreditationActive: boolean;
};
 
export type EmployeeAssessment = {
  readonly inDenominator: boolean;
  readonly inNumerator: boolean;
  readonly reasons: readonly ExclusionReason[];
};
 
export function assessEmployee(e: Employee): EmployeeAssessment {
  if (!isTargeted(e.gosiJobCode)) {
    // Outside the profession family: neither term. Not an exclusion, a filter.
    return { inDenominator: false, inNumerator: false, reasons: ['JOB_CODE_NOT_TARGETED'] };
  }
 
  // A non-Saudi accountant is denominator-only by definition.
  if (e.nationality !== 'SA') {
    return { inDenominator: true, inNumerator: false, reasons: [] };
  }
 
  const reasons: ExclusionReason[] = [];
 
  if (e.qualification === undefined) {
    // Never assume the lower floor to be generous. An unknown qualification is
    // a data incident to resolve, not a number to guess.
    reasons.push('QUALIFICATION_UNMAPPED');
  } else if (e.contributoryWageSar < WAGE_FLOOR_SAR[e.qualification]) {
    reasons.push('WAGE_BELOW_FLOOR');
  }
 
  if (!e.socpaAccreditationActive) {
    reasons.push('SOCPA_ACCREDITATION_INACTIVE');
  }
 
  return { inDenominator: true, inNumerator: reasons.length === 0, reasons };
}

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

المقصود بالأجر هو الأجر الخاضع للاشتراك المسجّل لدى التأمينات، وهو في الغالب ليس الرقم الذي يسمّيه تقرير الرواتب راتباً. إن لم تكن قد طابقت الرقمين بعد، فإن محرّك اشتراكات التأمينات الاجتماعية يغطي مواضع اختلافهما. المحاسب المسجّل بـ 5,800 ريال والمدفوع له 6,400 ريال مستبعَد، ولن يخبرك بذلك أي تقرير رواتب.

الخطوة 5: التقريب — حيث يكلّفك العدد العشري رأساً

النسبة مطلوبة مقرَّبة إلى أقرب عدد صحيح، والنصف يُقرَّب إلى أعلى. يحلّ دليل الوزارة منشأة بها 23 محاسباً: 23 × 50% = 11.5، والمطلوب 12. ومثالها في المرحلة الأولى يقرّب 9.2 نزولاً إلى 9.

والتنفيذ البديهي خاطئ:

// Wrong. Do not ship this.
const required = Math.round(total * (ratePercent / 100));

فـ ratePercent / 100 غير قابل للتمثيل بدقة في الفاصلة العائمة الثنائية عند 30 أو 70. فمع 45 محاسباً بنسبة 70%، تُقيَّم 45 * 0.7 إلى 31.499999999999996، وتُعيد Math.round القيمة 31 بينما الإجابة الصحيحة 32. ومع 85 محاسباً تُعيد 59 بدل 60. فتكون ناقصاً محاسباً سعودياً واحداً عن النسبة، ولوحتك خضراء.

أبقِ الحساب في الأعداد الصحيحة من أوله إلى آخره. فالنقاط المئوية أعداد صحيحة؛ اضرب أولاً، ثم أضف نصف المقسوم عليه، ثم اقسم:

// tawteen/required.ts
 
/**
 * Required Saudi accountants, rounded to nearest with halves rounding up.
 * Integer-only: `total * ratePercent` is exact, so no float ever enters.
 */
export function requiredSaudis(totalAccountants: number, ratePercent: number): number {
  return Math.floor((totalAccountants * ratePercent + 50) / 100);
}

مُتحقَّق منه في مقابل الدليل: requiredSaudis(23, 50) تُعيد 12، و requiredSaudis(23, 40) تُعيد 9. وفي مقابل فخّ الفاصلة العائمة: requiredSaudis(45, 70) تُعيد 32 و requiredSaudis(85, 70) تُعيد 60، وكلاهما صحيح حيث تخطئ Math.round.

واعرض النسبة المتحققة بالأعداد الصحيحة أيضاً. فبتر نقاط الأساس يخطئ نزولاً، وهو الاتجاه الآمن في رقم امتثال:

/** Achieved rate in basis points, truncated. 4000 = 40.00%. */
export function achievedBasisPoints(countableSaudis: number, totalAccountants: number): number {
  if (totalAccountants === 0) return 0;
  return Math.floor((countableSaudis * 10000) / totalAccountants);
}

الخطوة 6: تجميع التقييم

الآن اربط الأجزاء. لا ينبغي أن يكون مخرَج محرّك امتثال رقماً واحداً أبداً — بل رقماً مع الدليل الذي أنتجه، لأن الرقم وحده لا يمكن تدقيقه ولا التصرّف بناءً عليه.

// tawteen/assess.ts
import { assessEmployee, type Employee, type ExclusionReason } from './countable';
import { rateFor } from './rate';
import { achievedBasisPoints, requiredSaudis } from './required';
 
export type ExcludedSaudi = {
  readonly employeeId: string;
  readonly reasons: readonly ExclusionReason[];
};
 
export type Assessment =
  | { readonly inScope: false; readonly totalAccountants: number }
  | {
      readonly inScope: true;
      readonly phase: number;
      readonly ratePercent: number;
      readonly totalAccountants: number;
      readonly countableSaudis: number;
      readonly requiredSaudis: number;
      /** Heads short, by replacement. Zero when compliant. */
      readonly replacementGap: number;
      readonly achievedBasisPoints: number;
      readonly excludedSaudis: readonly ExcludedSaudi[];
    };
 
export function assess(roster: readonly Employee[], onDate: string): Assessment {
  let total = 0;
  let countable = 0;
  const excludedSaudis: ExcludedSaudi[] = [];
 
  for (const e of roster) {
    const a = assessEmployee(e);
    if (!a.inDenominator) continue;
 
    total += 1;
    if (a.inNumerator) {
      countable += 1;
    } else if (e.nationality === 'SA') {
      // A Saudi in the denominator but not the numerator is the remediation list.
      excludedSaudis.push({ employeeId: e.id, reasons: a.reasons });
    }
  }
 
  const rate = rateFor(total, onDate);
  if (!rate) return { inScope: false, totalAccountants: total };
 
  const required = requiredSaudis(total, rate.ratePercent);
 
  return {
    inScope: true,
    phase: rate.phase,
    ratePercent: rate.ratePercent,
    totalAccountants: total,
    countableSaudis: countable,
    requiredSaudis: required,
    replacementGap: Math.max(0, required - countable),
    achievedBasisPoints: achievedBasisPoints(countable, total),
    excludedSaudis,
  };
}

شغّله على قائمة صغيرة يسقط فيها محاسبان سعوديان لسببين مختلفين — أحدهما مسجّل بـ 5,800 ريال في مقابل حدّ بكالوريوس قدره 6,000، والآخر انتهى اعتماده — ومقام من خمسة رؤوس يقرؤه نظام الموارد البشرية على أنه 80% سعودي يخرج عند 40%. الرقمان غير متقاربين، وواحد منهما فقط هو الذي تحسبه الوزارة.

الخطوة 7: توظيف ثلاثة لا يسدّ فجوة قدرها ثلاثة

المثال المحلول في الدليل: 23 محاسباً، المرحلة الثانية، المطلوب 12، وتسعة سعوديين محتسَبين. ويقول: استبدل ثلاثة محاسبين غير سعوديين. وهذا صحيح — وهو صحيح تحديداً لأنه قال استبدل.

الاستبدال يُبقي المقام عند 23. أما التوظيف فينمّي الحدّين معاً، فلا تُسدّ فجوة قدرها ثلاثة بعد ثلاث تعيينات. أجرِ الحساب وراقب الهدف وهو يهرب:

سعوديون تم توظيفهمإجمالي المحاسبينالمطلوب عند 50%السعوديون المحتسَبونما يزال ناقصاً
32613121
42714131
52814140

خمس تعيينات صافية لسدّ فجوة قدرها ثلاثة. وكل لوحة متابعة تعرض «الفجوة: 3» مجرّدة وتدع مسؤول التوظيف يقرؤها هدفاً للتعيين إنما تكذب بنحو 67%. احسب الرقمين معاً وسمِّ كلّاً منهما:

// tawteen/gap.ts
import { requiredSaudis } from './required';
 
export type Remediation = {
  /** Non-Saudi accountants to replace with Saudis. Denominator unchanged. */
  readonly byReplacement: number;
  /** Net new countable Saudi hires, if you replace nobody. Denominator grows. */
  readonly byHiringOnly: number;
};
 
export function remediation(
  totalAccountants: number,
  countableSaudis: number,
  ratePercent: number,
): Remediation {
  const byReplacement = Math.max(0, requiredSaudis(totalAccountants, ratePercent) - countableSaudis);
 
  let hires = 0;
  // Each hire adds one to both terms; the target moves, so solve rather than divide.
  while (countableSaudis + hires < requiredSaudis(totalAccountants + hires, ratePercent)) {
    hires += 1;
    if (hires > 10_000) throw new Error('remediation did not converge');
  }
 
  return { byReplacement, byHiringOnly: hires };
}

تنتهي الحلقة عند أي نسبة دون 100%، وهي محدودة عملياً بعدد قليل من التكرارات، لكن أبقِ الحارس: فنسبة مستقبلية قدرها 100 ستدور إلى الأبد، ومهمّة امتثال تتعلّق بصمت عند الثالثة فجراً أسوأ من مهمّة تُطلِق استثناءً.

الخطوة 8: التاريخ الذي تسقط فيه دون أن يغادر أحد

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

// tawteen/forecast.ts
import { ACCOUNTING_PHASES } from './phases';
import { rateFor } from './rate';
import { requiredSaudis } from './required';
 
export type FutureBreach = {
  readonly effectiveFrom: string;
  readonly phase: number;
  readonly ratePercent: number;
  readonly requiredSaudis: number;
  readonly shortfall: number;
};
 
/** Phase dates on which the current roster would breach, if nothing changes. */
export function forecast(
  totalAccountants: number,
  countableSaudis: number,
  fromDate: string,
): FutureBreach[] {
  const out: FutureBreach[] = [];
 
  for (const phase of ACCOUNTING_PHASES) {
    if (phase.effectiveFrom <= fromDate) continue;
 
    const rate = rateFor(totalAccountants, phase.effectiveFrom);
    if (!rate) continue; // Still out of scope at that size.
 
    const required = requiredSaudis(totalAccountants, rate.ratePercent);
    if (countableSaudis >= required) continue;
 
    out.push({
      effectiveFrom: phase.effectiveFrom,
      phase: rate.phase,
      ratePercent: rate.ratePercent,
      requiredSaudis: required,
      shortfall: required - countableSaudis,
    });
  }
  return out;
}

عشرون محاسباً مع عشرة سعوديين محتسَبين ممتثلون في 27 أكتوبر 2026 — المطلوب 10، والموجود 10. وفي 27 أكتوبر 2027 يصبح المطلوب 12 وتصير القائمة نفسها ناقصة اثنين. وكان على المحرّك أن يقول ذلك قبل عام.

والدالة نفسها تلتقط دخول المرحلة الخامسة. فالمنشأة التي بها أربعة محاسبين وسعودي واحد محتسَب خارج النطاق حتى 27 أكتوبر 2029، ثم تحتاج إلى requiredSaudis(4, 30) وقيمتها 1 — أي ممتثلة. اجعلها أربعة محاسبين وصفر سعودي محتسَب، ينطلق التوقّع في تاريخ لم يضعه أحد في تقويمه.

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

ثلاث عائلات من الاختبارات، وأولاها ليست اختيارية.

ثبّت الأمثلة الرسمية المحلولة. فهي الفحص الخارجي الوحيد المتاح لك على قاعدة التقريب:

import { strict as assert } from 'node:assert';
import { requiredSaudis } from './required';
import { remediation } from './gap';
 
// Ministry procedural guide, phase two: 23 x 50% = 11.5 -> 12.
assert.equal(requiredSaudis(23, 50), 12);
// Phase one from the same guide: 9.2 -> 9.
assert.equal(requiredSaudis(23, 40), 9);
 
// The float trap: Math.round(45 * 0.7) is 31 and wrong.
assert.equal(requiredSaudis(45, 70), 32);
assert.equal(requiredSaudis(85, 70), 60);
 
// Replacement and hiring are different numbers, and the gap is the smaller one.
assert.deepEqual(remediation(23, 9, 50), { byReplacement: 3, byHiringOnly: 5 });

تحقّق من شكل الجداول المرجعية، لا من السلوك وحده. فقائمة رموز مهن قُصِّرت بصمت هي نمط الفشل الذي لن تلاحظه:

import { TARGETED_JOB_CODES } from './job-codes';
import { ACCOUNTING_PHASES } from './phases';
 
const codes = Object.keys(TARGETED_JOB_CODES);
assert.equal(new Set(codes).size, codes.length, 'duplicate job code');
assert.ok(codes.every((c) => /^\d{6}$/.test(c)), 'job codes are six digits');
assert.equal(ACCOUNTING_PHASES.length, 5);

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

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

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

المحرّك يقول 43% ولوحة الموارد البشرية تقول 52%. متوقّع، وهو سبب وجود المحرّك. اطبع excludedSaudis وستجد الفرق عادةً في اعتمادين أو ثلاثة انتهت صلاحيتها، وأجر خاضع للاشتراك يقلّ عن الحدّ ببضع مئات من الريالات.

كل الموظفين يعودون بـ JOB_CODE_NOT_TARGETED. أنت تقرأ المسميات الداخلية في نظام الموارد البشرية بدل الرموز المسجّلة لدى التأمينات. حقلان مختلفان يتباعدان باستمرار — وذلك التباعد هو النتيجة نفسها، لا خلل في عملية الربط.

النسبة تتذبذب بين قيمتين في تشغيلين متتاليين. شيء ما في المسار يعيد حساب total من استعلام حيّ بينما يأتي countableSaudis من لقطة مخزّنة. قيّم قائمة واحدة غير قابلة للتغيير مقروءة في لحظة واحدة، وإلا انتمى الحدّان إلى لحظتين مختلفتين.

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

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

  • اجعل التقييم يعمل ليلياً وقارنه بيوم أمس، ونبّه عند ظهور أي سعودي مستبعَد جديد لا عند تغيّر النسبة الرئيسية. فالاعتمادات تنتهي بهدوء.
  • أضف الالتزام الموازي. مهن إدارة المشاريع تبلغ 70% في 14 فبراير 2027 برموز مهن خاصة بها ومقام خاص بها، ويغطّيها شكل المحرّك نفسه.
  • اسحب القائمة المحتسَبة من المنصّة لا من جدول بيانات — تكامل قوى مع أنظمة الموارد البشرية يغطي مصدر بيانات العقود الموثّقة.
  • أبقِ عرض نطاقات إلى جانب هذا. محرّك المرفق رقم ١ يجيب عن سؤال مختلف عن الشركة نفسها، وكونك أخضر هناك لا يقول شيئاً عن هذا القرار.

الخلاصة

الجزء الصعب في هذا القرار ليس النسبة المئوية. الصعب أن الرقم الذي تحسبه الوزارة مركَّب من ثلاثة أنظمة تقرؤها مؤسستك منفصلة، وأن الحساب الذي يجمعها يعاقب العدد العشري والجدول اليدوي في الاتجاه ذاته — التفاؤل.

محرّك يخزّن النسب كأعداد صحيحة، ويحتفظ بكل أسباب السقوط لا بأولها، ويميّز الاستبدال عن التوظيف، ويتوقّع تاريخ المرحلة التالية، سيخبرك في أغسطس بما كنت ستكتشفه في نوفمبر. تلك هي القيمة كلها، ولم يبقَ منها إلا أقل من شهرين قبل 27 أكتوبر.

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