écrits/tutorial/2026/11
● Tutorial2 nov. 2026·24 min

Saoudisation des salles de sport en TypeScript : 15 % par site

Construisez en TypeScript le moteur de saoudisation des centres sportifs avant le 18 novembre 2026 : les 12 codes de métier de la décision 72002, le calcul par licence, le seuil de quatre travailleurs, la table d'arrondi du guide comme jeu de test, les coachs déclarés sous d'autres intitulés, et un planificateur de réseau qui montre pourquoi une embauche saoudienne ne comble pas un écart d'une personne.

À partir du 18 novembre 2026, tout centre sportif ou toute salle de sport privée en Arabie saoudite employant au moins quatre travailleurs dans douze métiers d'encadrement sportif devra compter 15 % de Saoudiens parmi eux. Il s'agit de la décision ministérielle n° 72002 du 27/05/1447 H, prise par le ministère des Ressources humaines et du Développement social avec le ministère des Sports, et sa période de grâce de douze mois s'achève ce jour-là. Nous avons couvert l'annonce dans les salles de sport saoudiennes doivent saoudiser 15 % de 12 métiers d'encadrement.

Le pourcentage est la partie facile. Le guide de procédure du ministère tranche trois points qui décident si votre tableau de bord dit vrai : le calcul se fait par site sous licence, pas par entreprise ; le seuil de quatre travailleurs ne compte que les métiers ciblés ; et le résultat est arrondi à la personne près, selon une table imprimée sur laquelle on peut tester. Un réseau peut être conforme sur son total et en infraction dans plusieurs sites.

Ce tutoriel construit le moteur qui répond à la question site par site, puis à l'échelle du réseau.

Prérequis

  • Node.js 20 ou plus récent et TypeScript 5.x
  • Un accès en lecture au code de métier enregistré sur le contrat de chaque travailleur dans Qiwa, par licence du ministère des Sports
  • L'export du planning des cours ou des réservations de coaching individuel, si vous voulez le contrôle des coachs déguisés de l'étape 5
  • Connaître le problème d'arrondi du moteur de saoudisation des métiers comptables aide, sans être indispensable

Ce que vous allez construire

Un module pur, sans aucune dépendance, qui répond à cinq questions pour n'importe quel site et n'importe quelle date :

  1. Ce site est-il concerné, par son enregistrement ou par ce qu'il exploite réellement ?
  2. Combien de travailleurs occupent les douze métiers ciblés, et combien de Saoudiens faut-il parmi eux ?
  3. Les sanctions s'appliquent-elles déjà, ou sommes-nous encore en période de grâce ?
  4. Qui fait du coaching sans métier de coaching sur son contrat ?
  5. À l'échelle du réseau, quels coachs saoudiens peuvent changer de site, et combien coûte le reste en remplacements ou en embauches ?

Chaque constante provient du guide, téléchargé sur hrsd.gov.sa le 2 novembre 2026. Chaque sortie ci-dessous a été produite en exécutant le code.

Étape 1 : ce que dit réellement le guide

Quatre règles du guide de procédure donnent sa forme à tout le moteur.

L'unité est le site. Le guide définit le site sportif comme toute entité commerciale autorisée par le ministère des Sports à exploiter un centre ou une salle de sport, et précise que chaque centre ou salle titulaire de sa propre licence constitue un site distinct pour l'application de la décision. Les succursales ne sont pas additionnées.

Le seuil compte les travailleurs ciblés. La section 3.2 applique la décision aux sites où le nombre total de travailleurs dans les métiers du tableau 2 atteint quatre ou plus. Un site avec trois coachs et vingt réceptionnistes, agents d'entretien et comptables est hors champ. La FAQ du guide reprend la même lecture : quatre travailleurs ou plus dans un métier sportif listé.

Le champ suit l'activité réelle. La décision vise les activités ISIC4 931221 (salles et centres sportifs pour hommes) et 931222 (pour femmes). La section 3.2 l'étend à tout site qui exerce l'une de ces activités sous un registre de commerce ou une licence portant une autre activité, et la FAQ confirme que l'activité réelle compte, quel que soit le registre.

L'arrondi se fait à l'entier le plus proche. Le guide imprime une table de 4 à 40 travailleurs ciblés et énonce la règle : 0,49 et moins s'arrondit à zéro, 0,50 et plus à un. Son exemple chiffré est un site de 23 personnes, dont 6 coachs sportifs et 4 entraîneurs de fitness dans les métiers ciblés : 10 × 15 % = 1,5, donc deux Saoudiens requis. Le directeur commercial, les comptables, les réceptionnistes et les agents de sécurité ne comptent pas.

Ce que le guide ne dit pas, et que ce moteur n'invente pas : un salaire plancher ou une condition GOSI pour qu'un Saoudien compte, un montant d'amende (le guide renvoie aux tableaux des infractions des décisions ministérielles 75913 et 44558), et si le transfert d'un travailleur entre deux licences d'une même entreprise exige une opération dans Qiwa. Ces points sont signalés là où ils comptent.

Étape 2 : les tables de référence

Mettez toutes les constantes dans un seul fichier, avec leur source en commentaire. Les douze codes viennent de la Classification saoudienne unifiée des métiers, telle que reprise au tableau 2 du guide.

Créez src/rules.ts :

// Ministerial Decision 72002 of 27/05/1447 AH and its procedural guide (HRSD).
 
/** Saudis required as a share of workers in the targeted occupations. */
export const RATE_PERCENT = 15;
 
/** The decision bites only at four or more workers in the targeted occupations. */
export const MIN_TARGETED_WORKERS = 4;
 
/** End of the 12-month grace period: 18/11/2026 = 8/6/1448 AH. */
export const ENFORCEMENT_FROM = '2026-11-18';
 
/** ISIC4 activity codes, guide table 1. */
export const TARGETED_ACTIVITIES: Readonly<Record<string, string>> = {
  '931221': "Men's sports halls and centres",
  '931222': "Women's sports halls and centres",
};
 
/** Saudi Standard Classification of Occupations codes, guide table 2. */
export const TARGETED_OCCUPATIONS: Readonly<Record<string, string>> = {
  '342201': 'Sports coach',
  '342203': 'Professional football coach',
  '342205': 'Professional ball sports coach',
  '342207': 'Professional self-defence coach',
  '342209': 'Professional racket sports coach',
  '342211': 'Professional aquatic sports coach',
  '342213': 'Professional ice sports coach',
  '342217': 'Professional equestrian coach',
  '342219': 'Professional athletics coach',
  '342221': 'Sports supervisor',
  '342301': 'Fitness trainer',
  '342302': 'Personal trainer',
};
 
export function isTargetedOccupation(code: string): boolean {
  return Object.prototype.hasOwnProperty.call(TARGETED_OCCUPATIONS, code);
}

Le code de métier qui compte est celui du contrat, pas l'intitulé utilisé en salle. Un coach principal enregistré comme superviseur sportif compte sous 342221. Un coach enregistré comme réceptionniste ne compte pas du tout, et c'est le problème que traite l'étape 5.

Étape 3 : arrondir sans nombre flottant

La règle du guide est l'arrondi au plus proche, la moitié vers le haut. Math.round(n * 0.15) paraît juste et constitue une mauvaise habitude : 0,15 n'a pas de représentation binaire exacte, et le moteur comptable a montré le même schéma faire perdre une personne entière à 70 %. Restez en entiers. Les points de pourcentage sont des entiers : multipliez d'abord, ajoutez la moitié du diviseur, puis divisez.

Créez src/required.ts :

import { MIN_TARGETED_WORKERS, RATE_PERCENT } from './rules';
 
/**
 * Saudis required in one outlet. The guide rounds to the nearest whole
 * number: 0.49 and below goes down, 0.50 and above goes up. Integer-only,
 * so no float ever enters.
 */
export function requiredSaudis(targetedWorkers: number): number {
  if (!Number.isInteger(targetedWorkers) || targetedWorkers < 0) {
    throw new RangeError(`targetedWorkers must be a whole number, got ${targetedWorkers}`);
  }
  if (targetedWorkers < MIN_TARGETED_WORKERS) return 0;
  return Math.floor((targetedWorkers * RATE_PERCENT + 50) / 100);
}

Le seuil vit ici, pas chez l'appelant. En dessous de quatre travailleurs ciblés, rien n'est exigé, et renvoyer zéro garde le reste du moteur simple. À exactement quatre, 4 × 15 % = 0,6 s'arrondit à un, ce qui correspond à la FAQ du guide : un site avec quatre travailleurs dans un métier sportif doit employer au moins un Saoudien.

Étape 4 : évaluer un site

Une évaluation a besoin de l'enregistrement du site, de ce qu'il exploite réellement et de son effectif. Le statut prend l'une de quatre valeurs, et l'ordre des contrôles compte : l'activité d'abord, le seuil ensuite, le taux en dernier.

Créez src/outlet.ts :

import { ENFORCEMENT_FROM, TARGETED_ACTIVITIES, isTargetedOccupation } from './rules';
import { requiredSaudis } from './required';
 
export interface Worker {
  id: string;
  /** The occupation code registered on the contract in Qiwa, not the floor title. */
  occupationCode: string;
  saudi: boolean;
}
 
export interface Outlet {
  /** Ministry of Sport licence number. One licence = one outlet. */
  licenceNo: string;
  name: string;
  /** ISIC4 activity on the commercial registration or licence. */
  registeredActivity: string;
  /** What the outlet actually runs, in ISIC4 codes. */
  actualActivities: string[];
  workers: Worker[];
}
 
export type OutletStatus = 'OUT_OF_SCOPE_ACTIVITY' | 'BELOW_THRESHOLD' | 'COMPLIANT' | 'SHORT';
 
export interface OutletAssessment {
  licenceNo: string;
  status: OutletStatus;
  targeted: number;
  saudis: number;
  required: number;
  gap: number;
  /** In scope only because of what it actually does, not what it registered. */
  scopeFromActualActivity: boolean;
  /** False during the grace period: the rate applies, the penalties do not. */
  enforceable: boolean;
}
 
const isTargetedActivity = (code: string): boolean =>
  Object.prototype.hasOwnProperty.call(TARGETED_ACTIVITIES, code);
 
export function assessOutlet(outlet: Outlet, asOf: string): OutletAssessment {
  const byRegistration = isTargetedActivity(outlet.registeredActivity);
  const byActual = outlet.actualActivities.some(isTargetedActivity);
  const targetedWorkers = outlet.workers.filter((w) => isTargetedOccupation(w.occupationCode));
  const targeted = targetedWorkers.length;
  const saudis = targetedWorkers.filter((w) => w.saudi).length;
  const required = requiredSaudis(targeted);
  const gap = Math.max(0, required - saudis);
 
  let status: OutletStatus;
  if (!byRegistration && !byActual) status = 'OUT_OF_SCOPE_ACTIVITY';
  else if (required === 0) status = 'BELOW_THRESHOLD';
  else status = gap === 0 ? 'COMPLIANT' : 'SHORT';
 
  return {
    licenceNo: outlet.licenceNo,
    status,
    targeted,
    saudis,
    required: status === 'OUT_OF_SCOPE_ACTIVITY' ? 0 : required,
    gap: status === 'SHORT' ? gap : 0,
    scopeFromActualActivity: !byRegistration && byActual,
    enforceable: asOf >= ENFORCEMENT_FROM,
  };
}

Deux champs méritent leur place. scopeFromActualActivity vaut vrai pour le site enregistré sous une autre activité qui exploite malgré tout un plateau de sport, par exemple une salle rattachée à une licence de commerce de détail ou d'hôtel. Affichez-le à part, car la personne qui lit le tableau de bord supposera que le site est exempté. enforceable vaut faux avant le 18 novembre 2026 : pendant la période de grâce, le guide se contente de l'application du taux sans mesures ni sanctions. Ensuite, la FAQ indique que la conformité s'impose immédiatement.

Les dates ISO se comparent correctement en tant que chaînes, ce qui explique que la comparaison du code n'ait besoin d'aucun parsing de date. Passez la date explicitement : un moteur de conformité qui lit l'horloge système ne peut être ni testé ni rejoué.

Étape 5 : des coachs sous d'autres intitulés

La section 7 du guide est celle sur laquelle la plupart des salles trébucheront. Elle prévoit que lorsqu'un site ne respecte pas le taux, ou confie une tâche d'un métier saoudisé à un travailleur non saoudien, directement ou indirectement, sous n'importe quel autre intitulé de poste, le ministère peut prendre toutes les mesures nécessaires pour faire appliquer la décision.

Votre fichier du personnel ne le montre pas. Votre planning, si. Le réceptionniste qui anime le cours de spinning de 19 h, ou le « conseiller commercial » qui donne des séances de coaching individuel, apparaît comme instructeur dans le système de réservation. Croisez les deux.

Créez src/disguised.ts :

import { isTargetedOccupation } from './rules';
import { Outlet, OutletAssessment, Worker, assessOutlet } from './outlet';
 
/** One coaching act from the class timetable or the PT booking system. */
export interface Session {
  licenceNo: string;
  instructorId: string;
}
 
/**
 * Non-Saudis registered outside the 12 codes who nonetheless lead sessions.
 * Section 7 of the guide treats giving them a targeted job's tasks, under
 * any other title, as a breach.
 */
export function disguisedCoaches(outlet: Outlet, sessions: Session[]): Worker[] {
  const leading = new Set(
    sessions.filter((s) => s.licenceNo === outlet.licenceNo).map((s) => s.instructorId),
  );
  return outlet.workers.filter(
    (w) => !w.saudi && !isTargetedOccupation(w.occupationCode) && leading.has(w.id),
  );
}
 
/** Re-assess as an inspector would if each disguised coach counted as a sports coach. */
export function exposure(outlet: Outlet, sessions: Session[], asOf: string): OutletAssessment {
  const flagged = new Set(disguisedCoaches(outlet, sessions).map((w) => w.id));
  const workers = outlet.workers.map((w) =>
    flagged.has(w.id) ? { ...w, occupationCode: '342201' } : w,
  );
  return assessOutlet({ ...outlet, workers }, asOf);
}

exposure répond à la question qu'un inspecteur poserait : si ces personnes étaient comptées pour ce qu'elles font réellement, le site resterait-il conforme ? Les compter sous 342201 (coach sportif) est notre lecture, pas celle du guide, et le code le dit. Elle doit seulement être assez juste pour montrer si le statut du site change.

Deux réserves. Un Saoudien qui anime des cours hors code de coaching n'est pas signalé, car l'infraction consiste à confier les tâches à un non-Saoudien. Et un enregistrement de séance est un indice, pas une preuve : un remplacement ponctuel peut avoir une explication. La sortie est une liste à examiner, pas un verdict.

Étape 6 : remplacement, embauche et dénominateur mobile

Quand un site est en dessous du taux, il existe deux façons de combler l'écart, et elles donnent deux nombres différents. Remplacer un non-Saoudien sur un poste ciblé n'augmente que le numérateur. Embaucher un Saoudien en plus augmente les deux, et à certains effectifs ce travailleur supplémentaire fait monter l'exigence d'une unité.

La même logique joue en sens inverse. Le départ d'un Saoudien réduit aussi le dénominateur, si bien qu'un site de 20 travailleurs ciblés dont 5 Saoudiens peut en céder deux, pas trois, en restant conforme.

Créez src/gap.ts :

import { requiredSaudis } from './required';
 
export interface Remediation {
  /** Replace non-Saudis in targeted seats: the denominator stays put. */
  byReplacement: number;
  /** Add Saudis on top: every hire also grows the denominator. */
  byHiringOnly: number;
}
 
export function remediation(targeted: number, saudis: number): Remediation {
  const byReplacement = Math.max(0, requiredSaudis(targeted) - saudis);
  let byHiringOnly = 0;
  while (saudis + byHiringOnly < requiredSaudis(targeted + byHiringOnly)) byHiringOnly++;
  return { byReplacement, byHiringOnly };
}
 
/**
 * How many Saudis in targeted seats could leave this outlet with it still
 * meeting the rate. Each departure shrinks the denominator as well.
 */
export function spareSaudis(targeted: number, saudis: number): number {
  let k = 0;
  while (k < saudis && saudis - (k + 1) >= requiredSaudis(targeted - (k + 1))) k++;
  return k;
}

Le cas qui surprend est 16. Un site de 16 travailleurs ciblés a besoin de 2 Saoudiens (2,4 s'arrondit vers le bas). Avec 1, l'écart est d'une personne. Embauchez un Saoudien : le site compte 17 travailleurs ciblés, qui en exigent 3 (2,55 s'arrondit vers le haut), et il lui en manque toujours un. remediation(16, 1) renvoie un remplacement ou deux embauches. La table du guide présente les mêmes paliers : de 16 à 17, de 23 à 24, de 29 à 30, de 36 à 37.

Étape 7 : planifier à l'échelle du réseau

Un réseau qui a des coachs saoudiens en surplus dans un site et un écart dans un autre peut souvent régler une partie du problème par des transferts avant de recruter. Le planificateur ci-dessous est glouton : il prend le site au plus grand écart, trouve le site au plus grand surplus, transfère une personne, réévalue les deux et recommence. Ce que les transferts ne couvrent pas est chiffré en remplacements ou en embauches.

Créez src/chain.ts :

import { isTargetedOccupation } from './rules';
import { Outlet, assessOutlet } from './outlet';
import { Remediation, remediation, spareSaudis } from './gap';
 
export interface Move {
  workerId: string;
  from: string;
  to: string;
}
 
export interface ChainPlan {
  moves: Move[];
  stillShort: Array<{ licenceNo: string } & Remediation>;
}
 
const clone = (outlets: Outlet[]): Outlet[] =>
  outlets.map((o) => ({ ...o, workers: o.workers.map((w) => ({ ...w })) }));
 
/**
 * Greedy plan: move a spare Saudi from the outlet with most room to the
 * outlet with the largest gap, re-assess both, repeat. Whatever is left
 * is priced as replacement or hiring.
 */
export function planChain(input: Outlet[], asOf: string): ChainPlan {
  const outlets = clone(input);
  const moves: Move[] = [];
  const saudiSeats = outlets.flatMap((o) => o.workers).filter((w) => w.saudi).length;
 
  // Each Saudi moves at most once per pass; the cap makes termination obvious.
  for (let pass = 0; pass < saudiSeats; pass++) {
    const rows = outlets.map((o) => ({ o, a: assessOutlet(o, asOf) }));
    const short = rows
      .filter((r) => r.a.status === 'SHORT')
      .sort((x, y) => y.a.gap - x.a.gap)[0];
    if (!short) break;
    const donor = rows
      .filter((r) => r.o !== short.o && r.a.status !== 'OUT_OF_SCOPE_ACTIVITY')
      .map((r) => ({ ...r, spare: spareSaudis(r.a.targeted, r.a.saudis) }))
      .filter((r) => r.spare > 0)
      .sort((x, y) => y.spare - x.spare)[0];
    if (!donor) break;
 
    const i = donor.o.workers.findIndex((w) => w.saudi && isTargetedOccupation(w.occupationCode));
    const [worker] = donor.o.workers.splice(i, 1);
    short.o.workers.push(worker);
    moves.push({ workerId: worker.id, from: donor.o.licenceNo, to: short.o.licenceNo });
  }
 
  const stillShort = outlets
    .map((o) => assessOutlet(o, asOf))
    .filter((a) => a.status === 'SHORT')
    .map((a) => ({ licenceNo: a.licenceNo, ...remediation(a.targeted, a.saudis) }));
 
  return { moves, stillShort };
}

Le planificateur ne modifie jamais son entrée et ne prend à un site donneur que ce que spareSaudis l'autorise à perdre. Un algorithme glouton n'est pas optimal pour tous les réseaux, mais pour la taille d'un réseau de salles il produit un plan qu'un responsable peut lire ligne par ligne, ce qui compte davantage.

Vérifiez avant de déplacer qui que ce soit. Le guide ne dit pas si le transfert d'un travailleur entre deux licences du ministère des Sports sous le même registre de commerce exige une opération dans Qiwa, ni à quel moment il apparaît dans le calcul. Traitez un transfert comme une option de planification et confirmez la procédure depuis votre compte Qiwa avant de vous y fier.

Étape 8 : exécution sur un réseau de trois sites

La démonstration modélise un réseau de trois sites. RYD-01 compte 16 travailleurs ciblés et un superviseur saoudien. RYD-02 est un centre pour femmes avec 20 travailleurs ciblés, dont cinq Saoudiens. JED-01 est enregistré comme commerce de détail d'articles de sport (477310) mais exploite une salle pour hommes, avec neuf travailleurs ciblés dont un Saoudien, deux réceptionnistes, et un planning qui montre l'un d'eux animant un cours.

Créez src/demo.ts :

import { Outlet, assessOutlet } from './outlet';
import { Session, disguisedCoaches, exposure } from './disguised';
import { planChain } from './chain';
 
const staff = (prefix: string, code: string, n: number, saudi: boolean) =>
  Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i + 1}`, occupationCode: code, saudi }));
 
const chain: Outlet[] = [
  {
    licenceNo: 'RYD-01', name: 'Riyadh North (men)',
    registeredActivity: '931221', actualActivities: ['931221'],
    workers: [
      ...staff('r1-pt-', '342302', 10, false),
      ...staff('r1-fit-', '342301', 5, false),
      ...staff('r1-sup-', '342221', 1, true),
      ...staff('r1-desk-', '422601', 3, false),
    ],
  },
  {
    licenceNo: 'RYD-02', name: 'Riyadh East (women)',
    registeredActivity: '931222', actualActivities: ['931222'],
    workers: [
      ...staff('r2-fit-', '342301', 15, false),
      ...staff('r2-sa-', '342201', 5, true),
    ],
  },
  {
    licenceNo: 'JED-01', name: 'Jeddah Corniche',
    registeredActivity: '477310', actualActivities: ['477310', '931221'],
    workers: [
      ...staff('j1-pt-', '342302', 8, false),
      ...staff('j1-sa-', '342201', 1, true),
      ...staff('j1-desk-', '422601', 2, false),
    ],
  },
];
 
const sessions: Session[] = [
  { licenceNo: 'JED-01', instructorId: 'j1-desk-1' },
  { licenceNo: 'JED-01', instructorId: 'j1-pt-1' },
];
 
const asOf = '2026-11-18';
for (const o of chain) {
  const a = assessOutlet(o, asOf);
  console.log(`${a.licenceNo}  ${a.status.padEnd(9)} targeted=${a.targeted} saudis=${a.saudis} required=${a.required} gap=${a.gap}${a.scopeFromActualActivity ? '  (in scope by actual activity)' : ''}`);
}
 
const jed = chain[2];
const hidden = disguisedCoaches(jed, sessions).map((w) => w.id);
const exp = exposure(jed, sessions, asOf);
console.log(`JED-01 disguised coaches: ${hidden.join(', ')} -> ${exp.status} required=${exp.required} gap=${exp.gap}`);
 
const plan = planChain(chain, asOf);
for (const m of plan.moves) console.log(`move ${m.workerId}: ${m.from} -> ${m.to}`);
console.log('still short:', JSON.stringify(plan.stillShort));

Lancez-le avec npx tsx src/demo.ts :

RYD-01  SHORT     targeted=16 saudis=1 required=2 gap=1
RYD-02  COMPLIANT targeted=20 saudis=5 required=3 gap=0
JED-01  COMPLIANT targeted=9 saudis=1 required=1 gap=0  (in scope by actual activity)
JED-01 disguised coaches: j1-desk-1 -> SHORT required=2 gap=1
move r2-sa-1: RYD-02 -> RYD-01
move r2-sa-2: RYD-02 -> RYD-01
still short: []

Trois constats qu'un tableur manquerait. RYD-01 a besoin de deux transferts pour combler un écart d'une personne, parce que le premier fait passer son exigence de deux à trois. JED-01 est concerné alors que son enregistrement indique du commerce de détail. Et JED-01 est conforme sur le papier, mais ne l'est plus dès que le réceptionniste qui donne des cours est compté comme coach.

Tester votre implémentation

Le guide vous offre une chose rare : une table imprimée que l'on peut figer comme jeu de test. Recopiez-la entièrement et testez chaque colonne, pas un échantillon. Les autres tests couvrent le seuil, la règle de l'activité réelle, la date d'entrée en application, le dénominateur mobile et le planificateur de réseau.

Créez tests/engine.test.ts :

import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { TARGETED_ACTIVITIES, TARGETED_OCCUPATIONS } from '../src/rules';
import { requiredSaudis } from '../src/required';
import { Outlet, assessOutlet } from '../src/outlet';
import { remediation, spareSaudis } from '../src/gap';
import { disguisedCoaches, exposure } from '../src/disguised';
import { planChain } from '../src/chain';
 
// Guide table 3, transcribed: targeted workers 4..40 -> Saudis required.
const TABLE_3: Array<[number, number]> = [
  [4, 1], [5, 1], [6, 1], [7, 1], [8, 1], [9, 1],
  [10, 2], [11, 2], [12, 2], [13, 2], [14, 2], [15, 2], [16, 2],
  [17, 3], [18, 3], [19, 3], [20, 3], [21, 3], [22, 3], [23, 3],
  [24, 4], [25, 4], [26, 4], [27, 4], [28, 4], [29, 4],
  [30, 5], [31, 5], [32, 5], [33, 5], [34, 5], [35, 5], [36, 5],
  [37, 6], [38, 6], [39, 6], [40, 6],
];
 
const staff = (prefix: string, code: string, n: number, saudi: boolean) =>
  Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i + 1}`, occupationCode: code, saudi }));
 
const outlet = (licenceNo: string, targeted: number, saudis: number, extra = 0): Outlet => ({
  licenceNo, name: licenceNo, registeredActivity: '931221', actualActivities: ['931221'],
  workers: [
    ...staff(`${licenceNo}-n`, '342301', targeted - saudis, false),
    ...staff(`${licenceNo}-s`, '342201', saudis, true),
    ...staff(`${licenceNo}-x`, '241101', extra, false),
  ],
});
 
test('reference tables have the shape the guide prints', () => {
  const codes = Object.keys(TARGETED_OCCUPATIONS);
  assert.equal(codes.length, 12);
  assert.ok(codes.every((c) => /^342[23]\d{2}$/.test(c)), 'codes are six digits in 3422xx or 3423xx');
  assert.deepEqual(Object.keys(TARGETED_ACTIVITIES).sort(), ['931221', '931222']);
});
 
test('requiredSaudis reproduces guide table 3 for every printed column', () => {
  for (const [targeted, saudis] of TABLE_3) assert.equal(requiredSaudis(targeted), saudis, `at ${targeted}`);
});
 
test('below four targeted workers nothing is required', () => {
  assert.equal(requiredSaudis(0), 0);
  assert.equal(requiredSaudis(3), 0);
  assert.throws(() => requiredSaudis(2.5), RangeError);
});
 
test('guide example 1: 23 staff, 10 in targeted jobs, 2 Saudis required', () => {
  const o: Outlet = {
    licenceNo: 'A', name: 'A', registeredActivity: '931221', actualActivities: ['931221'],
    workers: [
      ...staff('sales', '122101', 2, false), ...staff('coach', '342201', 6, false),
      ...staff('fit', '342301', 4, false), ...staff('acct', '241101', 3, false),
      ...staff('desk', '422601', 4, false), ...staff('guard', '541401', 4, false),
    ],
  };
  const a = assessOutlet(o, '2026-11-18');
  assert.equal(o.workers.length, 23);
  assert.deepEqual([a.status, a.targeted, a.required, a.gap], ['SHORT', 10, 2, 2]);
});
 
test('threshold counts targeted workers, not headcount', () => {
  const a = assessOutlet(outlet('B', 3, 0, 20), '2026-11-18');
  assert.equal(a.status, 'BELOW_THRESHOLD');
});
 
test('scope follows actual activity even when the registration says otherwise', () => {
  const o = { ...outlet('C', 6, 0), registeredActivity: '477310', actualActivities: ['477310', '931222'] };
  const a = assessOutlet(o, '2026-11-18');
  assert.equal(a.status, 'SHORT');
  assert.equal(a.scopeFromActualActivity, true);
  const out = assessOutlet({ ...o, actualActivities: ['477310'] }, '2026-11-18');
  assert.equal(out.status, 'OUT_OF_SCOPE_ACTIVITY');
});
 
test('penalties start on 18 November 2026, not the day before', () => {
  assert.equal(assessOutlet(outlet('D', 10, 0), '2026-11-17').enforceable, false);
  assert.equal(assessOutlet(outlet('D', 10, 0), '2026-11-18').enforceable, true);
});
 
test('at 16 targeted, one hire does not close a gap of one', () => {
  assert.deepEqual(remediation(16, 1), { byReplacement: 1, byHiringOnly: 2 });
  assert.deepEqual(remediation(10, 0), { byReplacement: 2, byHiringOnly: 2 });
  assert.deepEqual(remediation(20, 3), { byReplacement: 0, byHiringOnly: 0 });
});
 
test('spare Saudis account for the shrinking denominator', () => {
  assert.equal(spareSaudis(20, 5), 2);
  assert.equal(spareSaudis(17, 3), 1);
  assert.equal(spareSaudis(9, 1), 0);
  assert.equal(spareSaudis(4, 1), 1);
});
 
test('a non-Saudi receptionist who leads classes is flagged and changes the result', () => {
  const o = outlet('E', 9, 1);
  o.workers.push({ id: 'desk1', occupationCode: '422601', saudi: false });
  o.workers.push({ id: 'desk2', occupationCode: '422601', saudi: true });
  const sessions = [
    { licenceNo: 'E', instructorId: 'desk1' },
    { licenceNo: 'E', instructorId: 'desk2' },
    { licenceNo: 'OTHER', instructorId: 'E-n1' },
  ];
  assert.deepEqual(disguisedCoaches(o, sessions).map((w) => w.id), ['desk1']);
  assert.equal(assessOutlet(o, '2026-11-18').status, 'COMPLIANT');
  const x = exposure(o, sessions, '2026-11-18');
  assert.deepEqual([x.status, x.targeted, x.required], ['SHORT', 10, 2]);
});
 
test('chain plan needs two moves to close one gap at 16, and leaves the donor compliant', () => {
  const plan = planChain([outlet('R1', 16, 1), outlet('R2', 20, 5)], '2026-11-18');
  assert.equal(plan.moves.length, 2);
  assert.ok(plan.moves.every((m) => m.from === 'R2' && m.to === 'R1'));
  assert.deepEqual(plan.stillShort, []);
});
 
test('chain plan prices what transfers cannot cover and never mutates its input', () => {
  const input = [outlet('S1', 12, 0), outlet('S2', 9, 1)];
  const plan = planChain(input, '2026-11-18');
  assert.deepEqual(plan.moves, []);
  assert.deepEqual(plan.stillShort, [{ licenceNo: 'S1', byReplacement: 2, byHiringOnly: 2 }]);
  assert.equal(input[1].workers.length, 9);
});

Lancez npx tsx --test tests/engine.test.ts. Les douze tests passent. Nous avons aussi altéré le moteur de sept façons (arrondi vers le haut au lieu de la moitié vers le haut, seuil de cinq, activité réelle ignorée, application un jour trop tard, embauche qui ignore le dénominateur, départ qui l'ignore, Saoudiens signalés comme coachs déguisés), et chaque altération fait échouer au moins un test.

Dépannage

Le total du réseau est conforme mais des sites ne le sont pas. Normal. La décision se calcule par licence. Additionner les coachs du réseau en un seul chiffre est la manière la plus courante d'afficher une conformité qui n'existe pas.

Un site de quarante personnes ressort en BELOW_THRESHOLD. C'est juste si moins de quatre d'entre elles sont enregistrées sous les douze codes. Lancez alors l'étape 5 : quarante salariés et trois coachs enregistrés signifient généralement que le planning compte plus d'instructeurs que les contrats.

Tous les travailleurs ressortent comme non ciblés. Vous lisez les intitulés internes du SIRH au lieu du code de métier du contrat Qiwa. Les deux divergent, et la divergence est en elle-même le constat.

L'embauche a été validée mais le site reste en dessous du taux. Vérifiez les paliers de l'étape 6. À 16, 23, 29 et 36 travailleurs ciblés, une embauche nette relève aussi l'exigence.

Pour aller plus loin

  • Exécutez l'évaluation chaque nuit par licence et alertez sur tout changement de statut, pas seulement sur SHORT. Un site qui passe de BELOW_THRESHOLD à SHORT parce qu'un quatrième coach est arrivé, c'est l'alerte qui compte.
  • Alimentez les effectifs depuis la plateforme plutôt que depuis un tableur ; l'intégration de Qiwa aux systèmes RH explique d'où viennent les données de contrat authentifiées.
  • Gardez la vue de l'entreprise à côté de celle-ci. Le guide rappelle que Nitaqat et les autres décisions de saoudisation par métier continuent de s'appliquer ; son propre exemple compte trois comptables, qui relèvent du moteur de saoudisation des métiers comptables. Le calculateur Nitaqat montre la bande de l'entreprise entière, qui ne dit rien des 15 % de chaque site.
  • Quand une infraction apparaît, le premier symptôme visible est souvent un certificat de saoudisation refusé dans Qiwa.

Conclusion

La décision sur les salles de sport ressemble à un simple pourcentage. En pratique, c'est un calcul par licence, sur douze codes, à partir de données de contrat qui correspondent rarement au planning, avec un arrondi qui comporte des paliers où une embauche ne suffit pas. Un moteur qui compte par site, se teste sur la table même du guide, lit le planning à la recherche de coachs déguisés et planifie les transferts avant les embauches dira à un réseau où il en est avant le 18 novembre, plutôt qu'après une inspection.

Si vous exploitez plusieurs sites et voulez ce contrôle branché sur vos données Qiwa et de réservation plutôt que reconstruit à la main, réservez une séance de diagnostic gratuite. Nous regarderons où vivent vos codes de métier, vos licences et vos plannings, et à quel endroit ils cessent de concorder.