Le 8 novembre 2026, le ministère des Ressources humaines et du Développement social (HRSD) et le ministère des Municipalités et du Logement ont annoncé que le taux de saoudisation de 94 professions techniques d'ingénierie passe de 30% à 35% le 9 mai 2027. La décision s'applique à tout établissement privé employant au moins cinq personnes dans ces professions, compté au niveau de l'entité. Notre article d'actualité sur la décision détaille ce qui change. Ce tutoriel s'adresse à la personne qui doit produire le chiffre : le développeur des systèmes RH d'une entreprise de travaux, de maintenance ou d'un bureau d'études.
Le calcul paraît trivial. Il ne l'est pas, pour quatre raisons qui coûtent chacune une ou deux personnes : un technicien saoudien ne compte que si deux systèmes externes sont d'accord, la décision suit le travail et pas seulement l'intitulé du poste, l'embauche prévue pour mai doit être enregistrée en avril, et le « recruter 11 » de l'exemple du ministère ne veut pas dire ce qu'un recruteur comprendra.
Prérequis
- Node.js 20 ou plus récent, et TypeScript 5 en mode strict
- Un export des effectifs avec les codes de profession enregistrés dans Qiwa, la nationalité et le salaire cotisable déclaré à la GOSI
- Le statut d'accréditation auprès du Saudi Council of Engineers pour chaque technicien saoudien
- Le guide procédural actuel du HRSD pour la décision. Lors de notre vérification du 9 novembre 2026, le guide 2026 était annoncé mais pas encore téléchargeable : les règles ci-dessous viennent donc du guide 2025, qui fixait le taux de 30%. Relisez le nouveau guide avant de vous appuyer sur l'une d'elles.
Ce que vous allez construire
Un petit moteur, environ 190 lignes, qui répond à cinq questions pour n'importe quelle date :
- Quel taux s'applique : 30% jusqu'au 8 mai 2027, 35% à partir du 9 mai 2027
- Quelle décision régit chaque code de profession lorsque plusieurs décisions le couvrent
- Quels techniciens saoudiens comptent réellement, et toutes les raisons pour lesquelles les autres ne comptent pas
- Combien de Saoudiens il vous faut, par remplacement et par embauche nette, deux chiffres différents
- La dernière date à laquelle un changement doit être enregistré pour apparaître dans le décompte du ministère un jour donné
Il reprend la conception de notre moteur de quota de saoudisation des métiers comptables et du moteur par établissement pour les salles de sport. Quand une étape y est expliquée en détail, nous y renvoyons plutôt que de la répéter.
Étape 1 : ce que dit réellement le guide
Le guide procédural 2025 des professions techniques d'ingénierie fait huit pages. Voici les parties qui deviennent du code :
- Le taux est un pourcentage de l'ensemble des travailleurs des professions ciblées dans l'établissement. Il est de 30% depuis le 23 juillet 2025 selon le calendrier du guide (l'annonce du ministère indiquait le 27 juillet). Il passe à 35% le 9 mai 2027.
- Le champ couvre tout établissement privé comptant au moins cinq travailleurs dans les professions ciblées.
- L'accréditation professionnelle du Saudi Council of Engineers est une condition. Les techniciens non accrédités ne sont pas comptés dans le taux.
- Le plancher de salaire est un salaire mensuel déclaré à la GOSI d'au moins 5 000 SAR. Un technicien saoudien payé moins n'est pas compté.
- Le taux le plus élevé l'emporte. Lorsqu'une profession technique d'ingénierie est aussi visée par une autre décision de localisation à un taux différent, le guide applique le plus élevé.
- Intitulé et travail réel. La FAQ précise que la décision s'applique à l'intitulé professionnel et au travail effectivement exercé. Confier des tâches ciblées à un non-Saoudien, directement ou indirectement, sous un autre intitulé constitue une infraction.
- L'arrondi se fait à l'entier le plus proche, dans l'exemple chiffré du guide.
- Le contrôle est automatique. Selon la FAQ, le système rapproche les données GOSI des intitulés, des salaires et de l'accréditation, et les mises à jour de la GOSI et du Saudi Council of Engineers apparaissent sous cinq jours ouvrés au maximum.
Une autre réponse de la FAQ compte : un établissement dont tous les techniciens sont saoudiens doit quand même remplir les conditions. L'exemple du guide : 20 techniciens, tous saoudiens, dont au moins 6 doivent remplir les conditions de salaire et d'accréditation.
Étape 2 : les décisions comme données
Traitez chaque décision de saoudisation par profession comme un enregistrement avec des paliers datés, pas comme une constante. Vous ajouterez la prochaine décision, et le prochain palier de celle-ci, sans toucher à la logique.
/** A dated rate step: from this date (inclusive), this percentage applies. */
export interface RateStep {
readonly from: string; // ISO date, YYYY-MM-DD
readonly ratePercent: number; // integer, e.g. 35
}
export interface OccupationDecision {
readonly id: string;
/** Unified Saudi Classification of Occupations codes covered by the decision. */
readonly codes: ReadonlySet<string>;
/** Steps in date order. */
readonly steps: readonly RateStep[];
/** The decision applies only at or above this many workers in the covered codes. */
readonly minWorkers: number;
/** Minimum GOSI contributory wage, in SAR, for a Saudi to be counted. */
readonly minWageSar: number;
}
export const ENGINEERING_TECHNICIANS: OccupationDecision = {
id: 'engineering-technicians',
// Seven of the codes printed in the 2025 procedural guide. Load the full list
// from the current guide; do not ship this sample as your production table.
codes: new Set([
'311201', // civil engineering technician
'311211', // surveying technician
'311301', // electrical engineering technician
'311409', // electronics engineering technician
'311501', // mechanical engineering technician
'311511', // mechanical maintenance technician
'311805', // architectural draughtsman
]),
steps: [
{ from: '2025-07-23', ratePercent: 30 },
{ from: '2027-05-09', ratePercent: 35 },
],
minWorkers: 5,
minWageSar: 5000,
};
/** The rate in force on a date, or null before the first step. */
export function rateOn(decision: OccupationDecision, isoDate: string): number | null {
let rate: number | null = null;
for (const step of decision.steps) {
if (step.from <= isoDate) rate = step.ratePercent;
}
return rate;
}Les sept codes sont les sept professions citées dans l'annonce du ministère, telles qu'imprimées dans le tableau du guide 2025. Le vrai tableau compte 94 entrées. Chargez-le depuis le guide, pas depuis cet article, et versionnez-le avec la date du guide à côté, car la prochaine révision le modifiera.
Les dates sont des chaînes ISO, donc une simple comparaison de chaînes les ordonne correctement. Pas de fuseau horaire, pas d'analyse de Date, pas d'erreur d'un jour à minuit heure de Riyad.
Étape 3 : quand deux décisions couvrent un même code
Une entreprise de travaux relève rarement d'une seule décision. Les ingénieurs ont leur propre décision, la gestion de projet une autre, et un code peut figurer dans plusieurs tableaux. Le guide tranche avec une seule règle : le taux le plus élevé s'applique. En code, chaque travailleur appartient à une seule décision à une date donnée, celle qui a le taux le plus élevé parmi celles qui couvrent son code.
/**
* Where one code is covered by several decisions, the guide applies the higher rate.
* Returns the decision that governs this code on this date, or null if none does.
*/
export function governingDecision(
code: string,
isoDate: string,
decisions: readonly OccupationDecision[],
): OccupationDecision | null {
let best: OccupationDecision | null = null;
let bestRate = -1;
for (const d of decisions) {
if (!d.codes.has(code)) continue;
const rate = rateOn(d, isoDate);
if (rate !== null && rate > bestRate) {
best = d;
bestRate = rate;
}
}
return best;
}Conséquence à garder en tête : un travailleur peut passer d'un groupe à l'autre le jour où un taux augmente, sans aucun changement dans votre entreprise. Si vous calculez plusieurs décisions, calculez-les toutes à la même date et sur la même liste du personnel, sinon vous compterez certaines personnes deux fois et d'autres pas du tout.
Étape 4 : quels Saoudiens comptent
Un technicien saoudien ne compte que si trois conditions sont réunies : la nationalité, un salaire cotisable GOSI d'au moins 5 000 SAR, et une accréditation active du Saudi Council of Engineers. La première vient de votre SIRH, la deuxième de la GOSI, la troisième du Council. Votre tableau de bord RH ne connaît en général que la première.
export interface Worker {
readonly id: string;
readonly occupationCode: string;
readonly saudi: boolean;
/** Contributory wage registered with GOSI, in SAR. Not gross pay. */
readonly gosiWageSar: number;
/** Active professional accreditation with the Saudi Council of Engineers. */
readonly sceAccredited: boolean;
/**
* True when a line manager confirms the person does targeted technical work
* under a title outside the list. The decision follows the actual work.
*/
readonly doesTargetedWork?: boolean;
}
export type Exclusion = 'NOT_SAUDI' | 'WAGE_BELOW_FLOOR' | 'NOT_SCE_ACCREDITED';
/** Every reason this worker does not count toward the numerator. Empty means countable. */
export function exclusions(w: Worker, decision: OccupationDecision): Exclusion[] {
const reasons: Exclusion[] = [];
if (!w.saudi) reasons.push('NOT_SAUDI');
if (w.gosiWageSar < decision.minWageSar) reasons.push('WAGE_BELOW_FLOOR');
if (!w.sceAccredited) reasons.push('NOT_SCE_ACCREDITED');
return reasons;
}Renvoyez toutes les raisons, pas la première. Un technicien sous le plancher et non accrédité demande deux corrections, et celle de l'accréditation est la plus longue. Si vous ne signalez que le salaire, quelqu'un l'augmentera et découvrira le second problème en mai.
Le salaire, c'est le salaire cotisable déclaré à la GOSI, pas le brut de votre bulletin de paie. Les deux divergent plus souvent qu'on ne le croit. Le moteur de cotisations GOSI montre où.
Le champ doesTargetedWork est la façon dont le moteur applique la règle du travail réel. Aucun export ne vous dira qu'un non-Saoudien enregistré comme chef de chantier passe ses journées à faire le travail d'un technicien topographe. Seul un responsable hiérarchique le sait. Recueillez la réponse une fois par trimestre, conservez-la, et laissez le moteur intégrer ces personnes au champ et les lister à part.
Étape 5 : arrondir sans flottant
L'exemple du guide multiplie 51 techniciens par 30%, obtient 15,3 et arrondit à 15. En virgule flottante, 51 * 0.35 vaut 17.849999999999998, ce qui s'arrondit correctement ici par chance, mais d'autres valeurs ne seront pas aussi clémentes. Gardez le taux en pourcentage entier et arrondissez en entiers.
/**
* Required Saudis = workers x rate, rounded to the nearest whole number.
* Integer arithmetic only; a half rounds up (the guide does not say, this is our reading).
*/
export function requiredSaudis(workers: number, ratePercent: number): number {
return Math.floor((workers * ratePercent + 50) / 100);
}Le guide ne dit pas ce qui se passe exactement à la demie. Nous arrondissons la demie vers le haut, la lecture prudente, et le code le dit. Si le nouveau guide dit autre chose, c'est la seule ligne à changer. Le tutoriel sur le moteur des métiers comptables détaille l'erreur des flottants.
Étape 6 : évaluer l'établissement
On assemble maintenant les pièces. L'évaluation renvoie les deux chiffres d'écart, les Saoudiens exclus avec leurs raisons, et les personnes intégrées par la règle du travail réel.
export interface Assessment {
readonly decisionId: string;
readonly isoDate: string;
readonly applies: boolean;
readonly ratePercent: number;
readonly workers: number;
readonly countableSaudis: number;
readonly required: number;
/** Non-Saudis to replace with countable Saudis. Denominator unchanged. */
readonly byReplacement: number;
/** Net new countable Saudi hires if nobody leaves. Denominator grows. */
readonly byHiringOnly: number;
/** Saudis in scope who do not count, with every reason. */
readonly excluded: ReadonlyArray<{ id: string; reasons: Exclusion[] }>;
/** People counted in scope only because of the actual-work rule. */
readonly actualWorkFlags: readonly string[];
}
export function hiresOnly(workers: number, countable: number, ratePercent: number): number {
if (ratePercent >= 100) throw new Error('rate must be under 100%');
let k = 0;
while (countable + k < requiredSaudis(workers + k, ratePercent)) k++;
return k;
}
export function assess(
staff: readonly Worker[],
decision: OccupationDecision,
all: readonly OccupationDecision[],
isoDate: string,
): Assessment {
const rate = rateOn(decision, isoDate) ?? 0;
const inScope: Worker[] = [];
const actualWorkFlags: string[] = [];
for (const w of staff) {
if (governingDecision(w.occupationCode, isoDate, all) === decision) {
inScope.push(w);
} else if (w.doesTargetedWork && !w.saudi) {
inScope.push(w);
actualWorkFlags.push(w.id);
}
}
const workers = inScope.length;
const applies = rate > 0 && workers >= decision.minWorkers;
const excluded: { id: string; reasons: Exclusion[] }[] = [];
let countable = 0;
for (const w of inScope) {
const reasons = exclusions(w, decision);
if (reasons.length === 0) countable++;
else if (w.saudi) excluded.push({ id: w.id, reasons });
}
const required = applies ? requiredSaudis(workers, rate) : 0;
return {
decisionId: decision.id,
isoDate,
applies,
ratePercent: rate,
workers,
countableSaudis: countable,
required,
byReplacement: Math.max(0, required - countable),
byHiringOnly: applies ? hiresOnly(workers, countable, rate) : 0,
excluded,
actualWorkFlags,
};
}Deux choix de cette fonction méritent une explication.
Le dénominateur inclut chaque travailleur du champ, saoudien ou non, compté ou non. C'est ce qu'implique l'exemple du guide où tous les techniciens sont saoudiens : 20 techniciens, tous saoudiens, doivent en avoir 6 qui remplissent les conditions. Si les Saoudiens non éligibles sortaient du dénominateur, la réponse serait différente.
Le seuil utilise le même champ. Cinq travailleurs dans les professions ciblées, après la règle de chevauchement et celle du travail réel. Une entreprise avec quatre techniciens et un salarié intitulé ingénieur qui fait un travail de technicien en a cinq.
Étape 7 : « recruter 11 » ne veut pas dire recruter 11
L'exemple du guide : 51 techniciens, dont 4 Saoudiens, à 30%. Il en faut 15, donc le guide indique que l'établissement doit employer 11 techniciens saoudiens supplémentaires.
C'est juste si chacun des 11 remplace un non-Saoudien et que l'effectif reste à 51. C'est faux s'ils s'ajoutent à l'effectif. Avec 62 techniciens, l'exigence devient 18,6, arrondie à 19, et 15 Saoudiens ne suffisent pas. Par la seule embauche, l'établissement a besoin de 16 Saoudiens éligibles supplémentaires pour être conforme à 30%.
À 35%, le même établissement a besoin de 18 Saoudiens sur 51 : 14 par remplacement, ou 21 par la seule embauche.
| Taux | Requis sur 51 | Par remplacement | Par embauche seule |
|---|---|---|---|
| 30% | 15 | 11 | 16 |
| 35% | 18 | 14 | 21 |
Affichez les deux chiffres dans tout tableau de bord, avec leur libellé dans le type. Un simple « écart : 14 » lu comme objectif de recrutement laisse un chef de projet à court de sept personnes le 9 mai. La plupart des plans réels mélangent les deux : des remplacements à la fin de certains contrats, des embauches nettes pour de nouveaux chantiers. Les deux chiffres encadrent la réponse.
Étape 8 : la date limite d'enregistrement
Le ministère compte à partir des données de la GOSI et du Council, et le guide indique que les mises à jour apparaissent sous cinq jours ouvrés au maximum. Un technicien saoudien embauché le jeudi 6 mai 2027, ou accrédité ce jour-là, risque donc de ne pas figurer dans le décompte du dimanche 9 mai. Planifiez à rebours.
/** Saudi working week: Sunday to Thursday. Friday (5) and Saturday (6) are off. */
function isWorkingDay(d: Date): boolean {
const day = d.getUTCDay();
return day !== 5 && day !== 6;
}
/**
* Latest date a hire, wage change or accreditation must be registered so that it
* shows in the ministry's count by `isoDate`, given a sync lag in working days.
* The guide says updates show within five working days at most.
*/
export function registerBy(isoDate: string, lagWorkingDays = 5): string {
const d = new Date(`${isoDate}T00:00:00Z`);
let left = lagWorkingDays;
while (left > 0) {
d.setUTCDate(d.getUTCDate() - 1);
if (isWorkingDay(d)) left--;
}
return d.toISOString().slice(0, 10);
}Avec la semaine de travail saoudienne du dimanche au jeudi, cinq jours ouvrés avant le dimanche 9 mai 2027 tombent le dimanche 2 mai 2027. C'est le dernier jour sûr pour qu'une nouvelle embauche soit déclarée à la GOSI, qu'un salaire passe au-dessus de 5 000 SAR et qu'une accréditation soit active. L'accréditation elle-même prend plus de cinq jours : pour les jeunes diplômés, la vraie échéance tombe des semaines plus tôt. Demandez au Council son délai de traitement actuel et retranchez-le aussi.
Tester votre implémentation
Lancez les tests avec npx tsx --test engine.test.ts. Ils fixent les chiffres du guide lui-même, le cas de la FAQ où tous sont saoudiens, le seuil, la règle de chevauchement et le calcul des dates, et vérifient deux invariants sur chaque établissement de 5 à 300 techniciens.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ENGINEERING_TECHNICIANS as ET, assess, hiresOnly, rateOn,
registerBy, requiredSaudis, type OccupationDecision, type Worker,
} from './engine.ts';
function crew(n: number, saudi: number, code = '311201'): Worker[] {
return Array.from({ length: n }, (_, i) => ({
id: `w${i}`, occupationCode: code, saudi: i < saudi,
gosiWageSar: 6000, sceAccredited: true,
}));
}
test('rate steps from 30% to 35% on 9 May 2027', () => {
assert.equal(rateOn(ET, '2025-07-22'), null);
assert.equal(rateOn(ET, '2027-05-08'), 30);
assert.equal(rateOn(ET, '2027-05-09'), 35);
});
test("the guide's worked example: 51 technicians, 4 Saudi, at 30%", () => {
const a = assess(crew(51, 4), ET, [ET], '2026-11-09');
assert.equal(a.required, 15);
assert.equal(a.byReplacement, 11);
assert.equal(a.byHiringOnly, 16);
});
test('the same establishment at 35%', () => {
const a = assess(crew(51, 4), ET, [ET], '2027-05-09');
assert.equal(a.required, 18);
assert.equal(a.byReplacement, 14);
assert.equal(a.byHiringOnly, 21);
});
test('all-Saudi FAQ: 20 Saudis still need 6 who meet both conditions', () => {
const staff = crew(20, 20).map((w, i) =>
i < 6 ? w : { ...w, sceAccredited: false });
const a = assess(staff, ET, [ET], '2026-11-09');
assert.equal(a.required, 6);
assert.equal(a.byReplacement, 0);
assert.equal(a.excluded.length, 14);
});
test('four workers in scope: the decision does not apply', () => {
const a = assess(crew(4, 0), ET, [ET], '2027-05-09');
assert.equal(a.applies, false);
assert.equal(a.byHiringOnly, 0);
});
test('every failing reason is kept, not just the first', () => {
const [w] = crew(1, 1);
const a = assess(
[{ ...w!, gosiWageSar: 4800, sceAccredited: false }, ...crew(5, 0).map((x) => ({ ...x, id: `n${x.id}` }))],
ET, [ET], '2026-11-09');
assert.deepEqual(a.excluded[0]?.reasons, ['WAGE_BELOW_FLOOR', 'NOT_SCE_ACCREDITED']);
});
test('wage floor is inclusive at SAR 5,000', () => {
const staff = crew(10, 3).map((w, i) => (i === 0 ? { ...w, gosiWageSar: 5000 } : w));
assert.equal(assess(staff, ET, [ET], '2026-11-09').countableSaudis, 3);
});
test('actual-work rule pulls an off-list non-Saudi into scope', () => {
const staff: Worker[] = [
...crew(5, 2),
{ id: 'x', occupationCode: '999999', saudi: false, gosiWageSar: 3000,
sceAccredited: false, doesTargetedWork: true },
];
const a = assess(staff, ET, [ET], '2026-11-09');
assert.equal(a.workers, 6);
assert.deepEqual(a.actualWorkFlags, ['x']);
});
test('higher rate wins when two decisions cover one code', () => {
const other: OccupationDecision = {
id: 'other', codes: new Set(['311201']),
steps: [{ from: '2025-01-01', ratePercent: 50 }], minWorkers: 1, minWageSar: 4000,
};
const all = [ET, other];
assert.equal(assess(crew(10, 0), ET, all, '2026-11-09').workers, 0);
assert.equal(assess(crew(10, 0), other, all, '2026-11-09').workers, 10);
});
test('rounding: half rounds up, .3 rounds down, no float drift', () => {
assert.equal(requiredSaudis(10, 35), 4); // 3.5
assert.equal(requiredSaudis(51, 30), 15); // 15.3
assert.equal(requiredSaudis(51, 35), 18); // 17.85
assert.equal(requiredSaudis(3, 30), 1); // 0.9
});
test('hiring-only never beats replacement, and replacement always complies', () => {
for (let n = 5; n <= 300; n++) {
for (let s = 0; s <= n; s++) {
for (const r of [30, 35]) {
const req = requiredSaudis(n, r);
const rep = Math.max(0, req - s);
assert.ok(hiresOnly(n, s, r) >= rep);
assert.ok(s + rep >= requiredSaudis(n, r));
}
}
}
});
test('register by: five Saudi working days before Sunday 9 May 2027', () => {
assert.equal(registerBy('2027-05-09'), '2027-05-02');
assert.equal(registerBy('2027-05-09', 0), '2027-05-09');
});Le dernier test de propriété est celui qui justifie sa place. Sur environ 90 000 combinaisons de taille, de nombre de Saoudiens et de taux, il vérifie que l'embauche seule n'exige jamais moins de personnes que le remplacement, et que le chiffre de remplacement rend toujours l'établissement conforme. Une erreur d'une unité dans l'arrondi le fait échouer immédiatement.
Dépannage
Le moteur affiche moins de Saoudiens comptés que ce que les RH attendent. Affichez excluded. Les causes habituelles : une accréditation expirée au renouvellement, et un salaire déclaré à la GOSI un peu sous 5 000 SAR alors que la paie verse davantage.
L'effectif ne correspond pas au tableau de bord Qiwa. Vérifiez d'abord la règle de chevauchement : un code couvert par une décision à taux plus élevé sort de ce groupe. Puis regardez actualWorkFlags : ces personnes sont dans le champ parce qu'un responsable l'a dit, et Qiwa ne le sait pas.
Le guide 2026 modifie une règle. Chaque règle vit à un seul endroit : l'enregistrement de la décision, requiredSaudis ou exclusions. Modifiez l'enregistrement ou la fonction, et les tests vous diront quels exemples du guide ont bougé.
Sous cinq travailleurs, rien ne s'applique. C'est exact, et fragile. Deux embauches suffisent pour entrer dans le champ. Lancez l'évaluation sur votre plan de recrutement, pas seulement sur l'effectif actuel.
Prochaines étapes
- Chargez le tableau complet des 94 codes depuis le nouveau guide procédural dès que le HRSD le publie, et ajoutez un test vérifiant qu'il compte 94 entrées
- Ajoutez la décision des ingénieurs et la décision sur la gestion de projet à 70% à partir de février 2027 comme enregistrements supplémentaires, pour que la règle de chevauchement serve vraiment
- Vérifiez la situation à l'échelle de l'entité avec notre calculateur Nitaqat : la décision par profession s'applique quelle que soit votre bande Nitaqat, et le tutoriel sur le moteur Nitaqat couvre ce volet
- Alimentez le moteur avec des données en direct en connectant votre SIRH à Qiwa, et surveillez tout certificat de saoudisation refusé, souvent le premier signe visible d'une infraction
Conclusion
Le taux de 35% arrive dans six mois, assez pour un cycle de recrutement si vous connaissez l'écart réel dès maintenant. Le moteur vous donne cet écart sous deux formes honnêtes, nomme chaque Saoudien qui ne compte pas et pourquoi, suit le travail plutôt que l'intitulé, et transforme le 9 mai en la date qui compte vraiment : le 2 mai, moins le délai d'accréditation du Council.
Si vous préférez que ce contrôle tourne automatiquement sur vos données Qiwa, GOSI et d'accréditation plutôt que de le reconstruire à la main, parlez-nous. Nous regarderons où ces trois sources se trouvent dans votre entreprise, et où elles cessent de concorder.