écrits/tutorial/2026/11
● Tutorial11 nov. 2026·18 min

Saoudisation des superviseurs de logement : un contrôle en TypeScript

La décision ministérielle 62298 réserve le métier de superviseur de logement (code 515102) aux Saoudiens dans tout établissement qui exploite un logement collectif de 20 personnes ou plus, avec un salaire GOSI minimum de 5 000 SAR. Construisez le contrôle en TypeScript : périmètre des sites, intitulé contre travail réel, plancher salarial et plan d'action pour les RH.

Depuis le 1er février 2026, seuls des Saoudiens peuvent exercer le métier de superviseur de logement en Arabie saoudite. La décision ministérielle 62298 du 31 octobre 2025, prise par le ministère des Ressources humaines et du Développement social (HRSD) avec le ministère des Municipalités et du Logement, fixe le taux à 100 % pour le code de métier 515102. Elle couvre tout établissement qui gère ou exploite un logement collectif pour 20 personnes ou plus. Un superviseur saoudien n'est compté que si le salaire déclaré à la GOSI atteint au moins 5 000 SAR par mois.

La plupart des quotas portent sur un pourcentage. Pas celui-ci. À 100 %, il n'y a pas de ratio à calculer, donc le risque se déplace : vers le site qui a dépassé 20 résidents le mois dernier, vers le responsable de camp enregistré dans Qiwa sous un autre intitulé, et vers le superviseur saoudien déclaré à la GOSI à 4 500 SAR. Ce tutoriel construit le contrôle qui détecte ces trois cas. Il s'adresse au développeur RH ou systèmes d'une entreprise de BTP, de facility management ou d'hébergement de travailleurs.

Prérequis

  • Node.js 20 ou plus récent et TypeScript 5 avec strict activé
  • Un export de vos salariés depuis votre SIRH : nationalité, code de métier Qiwa, salaire de cotisation GOSI
  • La liste de vos sites d'hébergement avec le nombre de personnes logées dans chacun
  • Une idée du fonctionnement des décisions de saoudisation par métier. Si c'est votre première, lisez d'abord notre moteur par établissement pour les salles de sport

Ce que vous allez construire

Un petit module sans dépendance qui répond à quatre questions, à n'importe quelle date :

  1. L'établissement est-il concerné, et par quels sites ?
  2. Qui est superviseur de logement au sens de la décision : par l'intitulé, par le travail réel, ou les deux ?
  3. Quel est le problème pour chacun : nationalité, salaire, ou seulement l'intitulé déclaré ?
  4. Que doivent faire les RH : combien de Saoudiens recruter, à qui retirer les tâches, combien ajouter aux salaires déclarés, quels intitulés corriger dans Qiwa ?

Le code reprend la conception de notre moteur de quota pour les techniciens en ingénierie : la décision est une donnée, chaque règle est une petite fonction, et les tests utilisent les seuils du guide lui-même.

Étape 1 : ce que dit vraiment le guide

Nous avons lu la décision et son guide de procédure sur le site du HRSD. Voici les faits dont le code dépend :

RègleSource
Superviseur de logement, code 515102, réservé aux Saoudiens à 100 %Guide 3.1 et tableau 1
S'applique aux établissements qui gèrent ou exploitent un logement collectif pour 20 bénéficiaires ou plus en logement partagé : complexes résidentiels, cabines mobiles, immeubles d'habitationGuide 3.2
Un Saoudien n'est compté qu'avec un salaire mensuel de cotisation GOSI d'au moins 5 000 SARGuide 3.3
Un superviseur payé sous ce plancher constitue une infraction, et pas seulement un salarié « non compté »Guide, FAQ
Publiée le 31 octobre 2025, délai de grâce de trois mois, appliquée depuis le 1er février 2026Guide 3.5
S'applique aux intitulés de poste et au travail réellement effectuéGuide, FAQ
Confier une tâche du métier à un non-Saoudien, directement ou indirectement, sous un autre intitulé, est une infractionGuide 5
Sanctions selon les décisions ministérielles 75913 (19/05/1445) et 44558 (03/04/1446)Guide 5 et article 4 de la décision
S'applique quelle que soit la bande Nitaqat de l'établissementGuide, FAQ

Deux points que le guide ne précise pas. Il ne dit pas que chaque site doit avoir un superviseur : il dit qui peut occuper le poste, pas que le poste doit exister. Et il ne dit pas comment compter les 20 bénéficiaires lorsqu'il existe plusieurs petits sites. Nous lisons « 20 ou plus en logement partagé » site par site, et le code place cette lecture à un seul endroit pour que vous puissiez la changer.

Étape 2 : la décision sous forme de données

Tout ce que fixe le guide tient dans un seul enregistrement. Si le ministère modifie le plancher salarial, vous changez un nombre.

/** Ministerial Decision 62298 of 31 October 2025 and its procedural guide. */
export const HOUSING_SUPERVISOR = {
  decision: '62298',
  issued: '2025-10-31',
  enforcedFrom: '2026-02-01', // issue date plus a three-month grace period
  occupationCode: '515102', // "Housing supervisor" (مشرف مسكن)
  saudiRatePercent: 100,
  minGosiWageSar: 5000, // contributory wage registered with GOSI
  minBeneficiaries: 20, // collective housing for 20 or more people
} as const;
 
/** One collective housing site: a compound, a building, a set of caravans. */
export interface HousingSite {
  readonly id: string;
  readonly beneficiaries: number; // people housed there today
}
 
export interface Worker {
  readonly id: string;
  readonly saudi: boolean;
  readonly occupationCode: string; // as registered in Qiwa
  readonly gosiWageSar: number; // integer riyals, as registered with GOSI
  /** Sites this person supervises in practice, whatever the job title says. */
  readonly supervisesSiteIds: readonly string[];
}

Le champ supervisesSiteIds est le plus important, et c'est celui que votre SIRH n'a pas. Qiwa connaît les intitulés. Il ne sait pas qui remet réellement les clés des chambres, fait le comptage des résidents et signe la fiche de nettoyage au complexe de Jubail. Quelqu'un doit l'enregistrer, site par site. D'expérience, les responsables de site le savent en cinq minutes.

Étape 3 : quels sites font entrer l'établissement dans le périmètre

/** Sites that bring the establishment under the decision. */
export function sitesInScope(sites: readonly HousingSite[]): HousingSite[] {
  return sites.filter((s) => s.beneficiaries >= HOUSING_SUPERVISOR.minBeneficiaries);
}
 
/** On or after the enforcement date? ISO dates compare correctly as strings. */
export function isEnforced(onDate: string): boolean {
  return onDate >= HOUSING_SUPERVISOR.enforcedFrom;
}

Notez le >=. Le guide dit 20 ou plus : un site de 20 résidents exactement est concerné. C'est précisément l'erreur d'une unité que fait une implémentation rapide, et le premier test la vérifie.

Exécutez aussi ce contrôle sur votre plan d'occupation, pas seulement sur les chiffres du jour. Un site de 18 résidents qui accueille deux nouveaux travailleurs la semaine prochaine fait entrer tout l'établissement dans le champ de la décision.

Étape 4 : intitulé, travail réel, ou les deux

Le guide applique la décision à l'intitulé et au travail. Une personne est donc superviseur de logement si Qiwa le dit, si elle fait le travail, ou les deux. Les contrôles qui suivent diffèrent selon le cas.

export type RoleSource = 'title' | 'actual-work' | 'both';
 
/** Is this person a housing supervisor for the decision, and on what evidence? */
export function supervisorRole(w: Worker): RoleSource | null {
  const byTitle = w.occupationCode === HOUSING_SUPERVISOR.occupationCode;
  const byWork = w.supervisesSiteIds.length > 0;
  if (byTitle && byWork) return 'both';
  if (byTitle) return 'title';
  if (byWork) return 'actual-work';
  return null;
}
 
export type Finding =
  | { kind: 'non-saudi'; workerId: string; role: RoleSource }
  | { kind: 'wage-below-floor'; workerId: string; shortfallSar: number }
  | { kind: 'title-mismatch'; workerId: string };
 
/** Everything wrong with one worker. An empty list means this person is fine. */
export function checkWorker(w: Worker): Finding[] {
  const role = supervisorRole(w);
  if (role === null) return [];
  if (!w.saudi) return [{ kind: 'non-saudi', workerId: w.id, role }];
  const out: Finding[] = [];
  const shortfall = HOUSING_SUPERVISOR.minGosiWageSar - w.gosiWageSar;
  if (shortfall > 0) out.push({ kind: 'wage-below-floor', workerId: w.id, shortfallSar: shortfall });
  if (role === 'actual-work') out.push({ kind: 'title-mismatch', workerId: w.id });
  return out;
}

Les trois résultats correspondent à trois conversations différentes avec les RH :

  • Un non-Saoudien dans le rôle est une infraction, quel que soit l'intitulé. Un « coordinateur de camp » ou un « administrateur de site » qui fait le travail du superviseur est le cas que le guide vise à la section 5.
  • Un Saoudien sous 5 000 SAR est aussi une infraction. La FAQ le dit explicitement. L'écart en riyals figure dans le résultat, car c'est le chiffre dont la paie a besoin.
  • Un Saoudien qui fait le travail sous un autre intitulé n'enfreint pas la règle des 100 %, mais Qiwa ne le compte pas comme superviseur de logement : la photo officielle et la réalité divergent. La correction est un changement d'intitulé, pas un recrutement.

Le salaire visé est le salaire de cotisation déclaré à la GOSI, pas le brut de votre état de paie. C'est le salaire de base plus l'indemnité de logement : heures supplémentaires, commissions et autres indemnités ne comptent pas. Le moteur de cotisations GOSI montre comment les deux chiffres s'écartent.

Étape 5 : évaluer l'établissement

export interface Assessment {
  readonly onDate: string;
  readonly inScope: boolean;
  readonly enforced: boolean;
  readonly sitesInScope: readonly string[];
  readonly findings: readonly Finding[];
  /** In-scope sites that no compliant Saudi supervises. Information, not a breach. */
  readonly sitesWithoutCompliantSupervisor: readonly string[];
  readonly compliant: boolean;
}
 
export function assess(
  onDate: string,
  sites: readonly HousingSite[],
  workers: readonly Worker[],
): Assessment {
  const scoped = sitesInScope(sites).map((s) => s.id);
  const inScope = scoped.length > 0;
  const findings = inScope ? workers.flatMap(checkWorker) : [];
 
  const breached = new Set(
    findings.filter((f) => f.kind !== 'title-mismatch').map((f) => f.workerId),
  );
  // A non-Saudi supervisor always has a finding, so "not breached" means a compliant Saudi.
  const covered = new Set(
    workers.filter((w) => !breached.has(w.id)).flatMap((w) => w.supervisesSiteIds),
  );
 
  return {
    onDate,
    inScope,
    enforced: isEnforced(onDate),
    sitesInScope: scoped,
    findings,
    sitesWithoutCompliantSupervisor: scoped.filter((id) => !covered.has(id)),
    compliant: findings.every((f) => f.kind === 'title-mismatch'),
  };
}

Trois choix de conception méritent une explication.

Hors périmètre signifie aucun résultat. Si aucun site n'atteint 20 résidents, la décision ne s'applique pas : la fonction renvoie une liste vide plutôt que des résultats à filtrer ensuite.

Un intitulé erroné ne rend pas l'établissement non conforme. Ces cas sont listés pour être corrigés, mais compliant ne regarde que la nationalité et le salaire. Mélanger les deux vous ferait chercher un recrutement inutile.

sitesWithoutCompliantSupervisor est une information, pas une infraction. Le guide n'exige pas un superviseur par site. Mais un grand site où personne de conforme ne fait ce travail signifie souvent que quelqu'un de non conforme le fait, et c'est ce que vous cherchez. Traitez la liste comme une question à poser au responsable de site.

Étape 6 : des résultats au plan d'action

Les RH n'agissent pas sur des résultats. Elles agissent sur un nombre de recrutements, un montant de paie et une liste de noms.

export interface ActionPlan {
  /** Saudi supervisors to recruit, one per non-Saudi doing the job today. */
  readonly saudiHires: number;
  /** Non-Saudis whose supervision duties must stop, whatever their title. */
  readonly dutiesToReassign: readonly string[];
  /** Monthly increase in registered GOSI wages, in riyals. */
  readonly wageTopUpSarPerMonth: number;
  /** Saudis to re-register in Qiwa under code 515102. */
  readonly qiwaTitleChanges: readonly string[];
}
 
export function actionPlan(a: Assessment): ActionPlan {
  const nonSaudi = a.findings.filter((f) => f.kind === 'non-saudi');
  let topUp = 0;
  for (const f of a.findings) if (f.kind === 'wage-below-floor') topUp += f.shortfallSar;
  return {
    saudiHires: nonSaudi.length,
    dutiesToReassign: nonSaudi.map((f) => f.workerId),
    wageTopUpSarPerMonth: topUp,
    qiwaTitleChanges: a.findings
      .filter((f) => f.kind === 'title-mismatch')
      .map((f) => f.workerId),
  };
}

Chaque non-Saoudien dans le rôle doit être remplacé par un Saoudien pour ce travail, et ses tâches de supervision doivent cesser, même s'il garde son emploi dans un rôle réellement différent. Le complément salarial est la hausse mensuelle des salaires déclarés qui amène chaque superviseur saoudien à 5 000 SAR. Les cotisations GOSI patronales et salariales augmentent avec lui : transmettez le chiffre à la personne qui gère ce calcul.

Tester l'implémentation

Les tests utilisent les seuils du guide à leurs limites : 19 et 20 résidents, 4 999 et 5 000 SAR, 31 janvier et 1er février 2026.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { actionPlan, assess, checkWorker, type HousingSite, type Worker } from './engine.ts';
 
const sites: HousingSite[] = [
  { id: 'jubail-a', beneficiaries: 240 },
  { id: 'jubail-b', beneficiaries: 20 },
  { id: 'dammam-villa', beneficiaries: 19 },
];
 
function w(id: string, over: Partial<Worker> = {}): Worker {
  return { id, saudi: true, occupationCode: '515102', gosiWageSar: 5000,
    supervisesSiteIds: [], ...over };
}
 
test('scope starts at 20 beneficiaries, not 21', () => {
  const a = assess('2026-11-11', sites, []);
  assert.deepEqual(a.sitesInScope, ['jubail-a', 'jubail-b']);
  assert.equal(a.inScope, true);
});
 
test('an establishment with only small sites is out of scope', () => {
  const a = assess('2026-11-11', [{ id: 'v', beneficiaries: 19 }],
    [w('x', { saudi: false })]);
  assert.equal(a.inScope, false);
  assert.equal(a.findings.length, 0);
  assert.equal(a.compliant, true);
});
 
test('SAR 5,000 exactly counts; SAR 4,999 does not', () => {
  assert.deepEqual(checkWorker(w('ok')), []);
  assert.deepEqual(checkWorker(w('low', { gosiWageSar: 4999 })),
    [{ kind: 'wage-below-floor', workerId: 'low', shortfallSar: 1 }]);
});
 
test('a non-Saudi doing the work under another title is a breach', () => {
  const f = checkWorker(w('n', { saudi: false, occupationCode: '962101',
    supervisesSiteIds: ['jubail-a'] }));
  assert.deepEqual(f, [{ kind: 'non-saudi', workerId: 'n', role: 'actual-work' }]);
});
 
test('a Saudi doing the work under another title is a title fix, not a breach', () => {
  const a = assess('2026-11-11', sites,
    [w('s', { occupationCode: '432101', supervisesSiteIds: ['jubail-a', 'jubail-b'] })]);
  assert.equal(a.compliant, true);
  assert.deepEqual(actionPlan(a).qiwaTitleChanges, ['s']);
  assert.deepEqual(a.sitesWithoutCompliantSupervisor, []);
});
 
test('the plan adds up hires, duties and wage top-ups', () => {
  const a = assess('2026-11-11', sites, [
    w('a', { gosiWageSar: 4000, supervisesSiteIds: ['jubail-a'] }),
    w('b', { saudi: false, supervisesSiteIds: ['jubail-b'] }),
    w('c', { gosiWageSar: 4500 }),
  ]);
  assert.equal(a.compliant, false);
  assert.deepEqual(a.sitesWithoutCompliantSupervisor, ['jubail-a', 'jubail-b']);
  assert.deepEqual(actionPlan(a), { saudiHires: 1, dutiesToReassign: ['b'],
    wageTopUpSarPerMonth: 1500, qiwaTitleChanges: [] });
});
 
test('enforcement starts on 1 February 2026', () => {
  assert.equal(assess('2026-01-31', sites, []).enforced, false);
  assert.equal(assess('2026-02-01', sites, []).enforced, true);
});

Lancez-les avec :

npx tsx --test engine.test.ts

Nous avons aussi modifié volontairement chaque comparaison du moteur, par exemple >= en > sur le nombre de résidents et sur la date. Chaque modification de ce type fait échouer au moins un test : les tests verrouillent bien les règles.

Dépannage

Le contrôle trouve des superviseurs inconnus des RH. C'est la règle du travail réel qui joue son rôle. Regardez qui supervise ces sites en pratique et sous quel intitulé il figure dans Qiwa. Très souvent, c'est un chef d'équipe ou un chauffeur qui a repris la tâche peu à peu.

Un superviseur saoudien affiche un écart alors que la paie verse plus de 5 000 SAR. Le salaire déclaré à la GOSI est inférieur au salaire versé. Mettez la déclaration à jour. Le guide compte le chiffre GOSI, pas votre bulletin.

L'établissement entre et sort du périmètre. Un site oscille autour de 20 résidents. Fixez une marge, par exemple considérer 18 et plus comme concerné, et laissez l'enregistrement de la décision inchangé pour que le seuil légal reste visible.

Votre bande Nitaqat est verte mais ce contrôle échoue. Les deux sont possibles. Le guide dit que la décision par métier s'applique quelle que soit la bande. Notre calculateur Nitaqat répond à la question au niveau de l'entité, et ce module à celle du métier.

Pour aller plus loin

Conclusion

La décision sur les superviseurs de logement est courte, et c'est là le piège. À 100 %, il n'y a pas de ratio à surveiller : une entreprise peut être en infraction à cause d'un seul site qui a grandi, d'un chef d'équipe mal intitulé ou d'une déclaration GOSI inférieure de quelques centaines de riyals. Le module ci-dessus transforme ces trois cas en une liste de noms et trois chiffres sur lesquels les RH peuvent agir, à n'importe quelle date.

Si vous préférez que ce contrôle tourne automatiquement sur vos données Qiwa, GOSI et d'occupation plutôt que de le reconstruire à la main, parlez-nous. Nous regarderons où se trouvent ces trois sources dans votre entreprise et où elles cessent de concorder.