écrits/tutorial/2026/11
● Tutorial13 nov. 2026·20 min

Saoudisation de la gestion de projet à 70 % : un contrôle TypeScript

La décision ministérielle 41454 porte la saoudisation des métiers de gestion de projet à 70 % au 14 février 2027, avec une nouvelle liste de trois codes et sans plancher de salaire dans le guide. Construisez le contrôle en TypeScript : périmètre de l'entité, arrondi, remplacement ou recrutement, et comparaison avec les règles de 2023.

Le 13 août 2026, le ministère saoudien des Ressources humaines et du Développement social a publié la décision ministérielle 41454, accompagnée d'un guide de procédure mis à jour pour les métiers de la gestion de projet. À partir du 14 février 2027, tout établissement privé employant trois personnes ou plus dans ces métiers doit compter 70 % de Saoudiens parmi elles, calculés sur l'ensemble de l'entité.

La plupart des systèmes RH contiennent déjà une règle pour ces postes : le guide de 2023 de la décision 141749, qui impose 40 % depuis décembre 2024. Passer le taux de 40 à 70 est la partie facile. Le nouveau guide change aussi les codes de métier qui comptent, et il ne mentionne plus de plancher de salaire. Un contrôle qui ne modifie que le pourcentage comptera les mauvaises personnes.

Ce tutoriel construit ce contrôle en TypeScript, à partir des deux guides officiels, avec des tests exécutables. Il s'adresse au développeur ou au responsable des systèmes RH qui doit rendre les chiffres justes avant février, pas à la personne qui décide de se conformer. Si vous en êtes à cette étape, notre résumé de la décision et le calendrier des échéances de saoudisation 2027 sont de meilleurs points de départ.

Prérequis

  • Node.js 20 ou plus récent et TypeScript 5
  • Un export de vos effectifs avec, pour chaque salarié : la nationalité, le code de métier enregistré dans Qiwa, la succursale, l'entité de rattachement et le salaire soumis à cotisation GOSI
  • Une colonne supplémentaire que la plupart des exports n'ont pas encore : la personne fait-elle réellement de la gestion de projet. Elle vient des responsables hiérarchiques, pas du système
  • Une connaissance de base des termes Qiwa et Nitaqat

Ce que vous allez construire

Un petit module qui répond à cinq questions pour chaque entité :

  1. Combien de salariés entrent dans le périmètre du guide 2026, et la décision s'applique-t-elle seulement ?
  2. Combien de Saoudiens sont requis, avec l'arrondi du guide ?
  3. Quel est l'écart si vous remplacez des non-Saoudiens, et quel est-il si vous recrutez seulement ?
  4. Qui entre ou sort du périmètre par rapport aux règles de 2023 que votre système applique aujourd'hui ?
  5. Quels salariés créent un risque parce que leur code et leur travail réel ne correspondent pas ?

Le résultat est un plan d'action lisible par les RH, avec le nombre de jours restants dans la période de grâce.

Étape 1 : ce que dit vraiment le guide

Tout ce qui suit provient de deux documents publiés sur le site du ministère : la lettre de décision (n° 41454) et le guide de procédure de huit pages qui l'accompagne. La décision précise que le guide en fait partie intégrante.

Le taux. 70 % de l'ensemble des salariés des métiers ciblés, au niveau de l'entité (guide, section 3.1).

Le périmètre. Tout établissement privé opérant sur le marché saoudien qui emploie trois salariés ou plus dans les métiers du tableau 1 (section 3.2). Un établissement qui en emploie deux est hors décision.

L'entité. Le guide la définit comme l'unité que le ministère utilise pour la saoudisation : toutes les succursales de même activité économique détenues par un même établissement. Vos trois chefs de projet à Riyad, Djeddah et Dammam forment un groupe de trois, pas trois groupes d'un.

Les métiers. Le tableau 1 liste exactement trois codes de la Classification saoudienne unifiée des professions :

CodeMétier
121314Directeur de gestion de projets
214909Ingénieur en gestion de projets
242108Spécialiste en gestion de projets

L'arrondi. L'exemple chiffré de la page 5 prend cinq salariés : 70 % de 5 font 3,5, « arrondi à l'entier le plus proche », donc quatre Saoudiens sont requis. Avec deux Saoudiens déjà en poste, l'établissement doit remplacer deux non-Saoudiens. Une note de bas de page ajoute que si des Saoudiens sont ajoutés au lieu de remplacer, le calcul est refait sur le nouveau total.

Chevauchement. La clause trois de la décision et la section 3.2 du guide disent la même chose : si une autre décision cible le même métier à un taux différent, le taux le plus élevé s'applique.

Dates. Publication le 13 août 2026, six mois de grâce, application à partir du 14 février 2027 (section 3.5). La FAQ confirme que les services du ministère restent accessibles pendant la période de grâce, même si le taux n'est pas atteint.

Sanctions. La section 5 applique le barème des infractions de la décision ministérielle 112377 (21/8/1447 de l'hégire). Elle nomme aussi une infraction précise : confier une tâche de ces métiers à un non-Saoudien, directement ou indirectement, sous n'importe quel autre intitulé de poste.

La FAQ ajoute trois points qui comptent pour le code :

  • La décision s'applique quelle que soit la bande Nitaqat de l'établissement. Une bande verte ne vous exempte pas.
  • Elle s'applique aux intitulés de métier du tableau 1 et au travail réel du salarié.
  • La conformité est calculée automatiquement à partir des intitulés de métier de la base GOSI et des données des organismes de tutelle.

Ce qui change par rapport au guide de 2023

Le guide de 2023 de la décision 141749 (11/09/1444 de l'hégire) fixait 35 % puis 40 %, et il diffère sur deux points plus importants que le taux :

  • Sept codes, pas trois. Il listait 121314 et 242108, plus cinq anciens codes à sept chiffres : 2133241 chef de projet, 2133231 spécialiste du bureau de gestion de projets, 1216031 chef de projet télécommunications, 1217011 chef de projet services aux entreprises et 1216011 chef de projet transport. Le tableau 2026 supprime ces cinq codes et ajoute 214909, ingénieur en gestion de projets.
  • Un plancher de salaire. La section 1.2 du guide de 2023 prévoyait qu'un Saoudien ne compte que si son salaire soumis à cotisation GOSI atteint au moins 6 000 SAR. Le guide de 2026 ne contient cette clause nulle part dans ses huit pages.

La décision ne dit pas expressément que le plancher est supprimé. Sa clause six abroge ce qui la contredit, et sa clause trois maintient les décisions antérieures là où elles concordent. Notre lecture est que le plancher a disparu, puisque le guide qui définit les règles de calcul ne le contient plus. Demandez une confirmation au ministère ou à votre interlocuteur Qiwa avant de compter un Saoudien payé moins de 6 000 SAR. Le code ci-dessous garde le plancher comme paramètre, que vous pouvez réactiver en une ligne.

Étape 2 : les règles comme données

Gardez chaque guide comme un enregistrement, jamais comme des constantes dispersées dans le code. Quand le ministère publiera la prochaine mise à jour, vous ajouterez un enregistrement et les tests vous diront ce qui a changé.

// 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,
};

Le taux est un pourcentage entier, volontairement. L'étape 3 explique pourquoi.

Étape 3 : arrondir sans virgule flottante

En JavaScript, 45 * 0.7 donne 31.499999999999996, pas 31,5. Math.round renvoie alors 31 là où la règle du guide donne 32 : un Saoudien de moins, avec un rapport qui affirme que vous êtes conforme. Le même problème apparaît à 85, 165 et 175 salariés. Travailler en pourcentages entiers l'évite : multipliez l'effectif par 70, ajoutez 50, divisez par 100 et laissez tomber le reste. C'est un arrondi à l'entier le plus proche, la moitié allant vers le haut, comme dans l'exemple du guide.

// 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;
}

Les deux fonctions d'écart répondent à deux questions différentes. Le remplacement garde l'effectif : on remplace un non-Saoudien par un Saoudien et seul le numérateur bouge. Le recrutement ajoute des personnes : chaque nouveau Saoudien augmente à la fois le numérateur et le dénominateur, d'où la note du guide qui demande de refaire le calcul.

L'écart est important à 70 %. Sur l'exemple même du guide, cinq salariés dont deux Saoudiens, le remplacement demande deux personnes et le recrutement seul en demande quatre. Avec dix salariés dont trois Saoudiens, le remplacement en demande quatre et le recrutement seul douze. La boucle s'arrête parce qu'à tout taux inférieur à 100 %, la part de Saoudiens monte à chaque recrutement. La protection pour 100 % existe pour que la même fonction serve aux décisions qui réservent entièrement un métier.

Étape 4 : le périmètre, c'est le code et le travail

La FAQ est explicite : la décision s'applique à l'intitulé classifié et au travail réel. Cela donne deux cas d'échec distincts, et le module enregistre les deux :

  • Un salarié porte l'un des trois codes mais ne fait pas de gestion de projet. Nous le laissons hors périmètre. Quand le travail et le code divergent dans ce sens, la bonne correction est de rectifier l'enregistrement, et le rapport doit le rendre visible plutôt que de compter la personne.
  • Un salarié fait de la gestion de projet sous un autre code. Pour un non-Saoudien, la section 5 en fait une infraction à part entière. Pour un Saoudien, c'est un comptage perdu : il n'aide votre taux qu'une fois le bon code enregistré.
// 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,
  };
}

Trois détails de cette fonction viennent directement du guide :

  • Les salariés sont filtrés par entityId, pas par succursale, donc le seuil de trois et les 70 % se calculent sur toutes les succursales.
  • Le test de seuil est n >= rule.minWorkers. Deux salariés, c'est hors champ ; trois, c'est dedans.
  • Le plancher de salaire ne s'applique que si la règle en contient un. Dans PM_2026 il vaut null ; dans PM_2023, un Saoudien sous 6 000 SAR est exclu et signalé.

Le taux est affiché avec une décimale, mais la conformité ne l'utilise jamais. Elle compare des effectifs, parce que le guide raisonne en personnes entières.

Étape 5 : quand deux décisions couvrent un même code

Les ingénieurs ont leur propre décision de saoudisation, et le code 214909 se trouve dans le groupe des ingénieurs de la classification. Savoir si une décision d'ingénierie donnée liste le 214909 se vérifie dans son propre guide. Le résolveur ci-dessous ne devine pas ; il applique la règle énoncée par les deux guides : parmi les décisions en vigueur à une date, le taux le plus élevé l'emporte.

// 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];
}

Il gère aussi la transition. Jusqu'au 13 février 2027, c'est la règle de 2023 à 40 % qui est en vigueur. À partir du 14 février, la règle de 2026 à 70 % prend le relais pour les codes qu'elle couvre. Donnez-lui toutes les décisions que vous suivez et il renvoie celle qui régit chaque salarié à la date demandée.

Étape 6 : comparer les listes de 2023 et de 2026

C'est l'étape que la plupart des équipes sauteront, et celle qui attrape les erreurs coûteuses. Appliquez l'ancienne et la nouvelle règle aux mêmes effectifs et listez qui change de côté.

// 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],
  };
}

Ce qu'il faut attendre sur un vrai export :

  • Les entrants : toute personne enregistrée sous 214909, ingénieur en gestion de projets. Hors de la règle des 40 % jusqu'ici, elle entre maintenant dans le dénominateur, et si elle n'est pas saoudienne, elle augmente le nombre requis.
  • Les sortants : toute personne encore sous 2133241, 2133231, 1216031, 1217011 ou 1216011. Ces codes à sept chiffres relèvent de l'ancienne classification. Avant de vous réjouir d'un dénominateur plus petit, vérifiez ce que Qiwa contient réellement pour ces personnes aujourd'hui. Si leurs codes ont été migrés vers un code actuel à six chiffres, elles font peut-être déjà partie des trois.
  • Plancher de salaire : 6 000 SAR avant, aucun dans le guide 2026. Les Saoudiens exclus pour un salaire trop bas peuvent désormais compter, sous réserve de la confirmation évoquée à l'étape 1.

Étape 7 : des constats au plan d'action

Les RH ne lisent pas des objets. Transformez le résultat en lignes actionnables, avec la période de grâce comptée en jours.

// 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;
}

Tester votre implémentation

Ces tests figent chaque chiffre donné par le guide, ainsi que les cas limites autour. Lancez-les avec 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/);
});

Dix tests, tous au vert sur Node 20 et au-delà, avec tsc --strict sans erreur. Nous avons aussi modifié le code volontairement (arrondi avec 49 au lieu de 50, Math.round sur un taux à virgule flottante, seuil strict, mauvais ordre de tri, suppression du filtre sur le travail réel, date d'effet ignorée, suppression de la protection à 100 %) et vérifié qu'au moins un test échoue à chaque fois.

Un contrôle que les tests ne peuvent pas faire : comparer le résultat du module avec ce que Qiwa affiche pour la même entité. Le ministère calcule à partir des données GOSI. Si les deux divergent, la cause est presque toujours dans les données : un code enregistré autrement dans GOSI que dans votre SIRH, ou une succursale rattachée à une autre entité.

Dépannage

Qiwa affiche un effectif différent de votre export. Comparez d'abord le regroupement par entité. Des succursales d'activité économique différente peuvent former une entité distincte, et une succursale que vous traitez à part peut être regroupée avec le siège.

Un Saoudien n'est pas compté alors que son salaire dépasse 6 000. Vérifiez le code enregistré. Un chef de projet saoudien enregistré sous un code de gestion générique ne compte pas, quel que soit son vrai travail. Le constat title-mismatch avec saudi: true correspond à ce cas.

Les chiffres sautent le 14 février. C'est le résolveur qui passe de la règle à 40 % à la règle à 70 %. Faites l'évaluation pour les deux dates pendant la période de grâce, pour que le saut soit prévu.

Vous suivez plusieurs décisions et les effectifs semblent doublés. Chaque salarié doit être évalué selon une seule règle à une date donnée. Utilisez ruleFor pour attribuer la règle à chaque salarié, puis regroupez par règle avant d'appeler assessEntity.

Cinq salariés, trois Saoudiens, et le module dit qu'il en manque. 70 % de 5 font 3,5, arrondi à 4. Trois ne suffisent pas. C'est précisément le cas que le guide prend en exemple.

Pour aller plus loin

Conclusion

Le chiffre de 70 % fait le titre, mais ce n'est pas là que les systèmes se tromperont. Ils se tromperont sur la liste des codes (trois aujourd'hui, dont le 214909, et non les sept de 2023), sur le plancher de salaire que le guide 2026 ne mentionne plus, sur un calcul par succursale au lieu de l'entité, et sur les salariés dont l'intitulé ne correspond pas au travail réel. Chacun de ces points tient en quelques lignes de code et un test, et la comparaison de l'étape 6 vous montre, sur vos propres données, lesquels vous concernent.

Il vous reste trois mois de grâce. C'est assez pour corriger les enregistrements dans Qiwa, ce qui ne coûte rien, avant de décider combien de personnes recruter, ce qui coûte cher.

Si vous voulez ce contrôle branché sur votre SIRH et exécuté avant chaque recrutement, réservez une séance de diagnostic gratuite et nous examinerons vos données avec vous à la lumière du guide 2026.

Envoyez-nous votre projet

Nous répondons sous un jour ouvré.