écrits/tutorial/2026/11
● Tutorial6 nov. 2026·23 min

Déclarer la formation sur Qiwa en TypeScript avant le 31/01

Construisez un petit outil TypeScript qui transforme vos données RH et de formation en chiffres que les établissements saoudiens d'au moins 50 salariés doivent déclarer sur Qiwa avant le 31 janvier 2027 : effectif au 31 décembre, stagiaires ayant terminé dans les quatre catégories du ministère, heures et coûts, contrôle des huit unités, objectif de 2 % de diplômés, déclaration de non-réalisation et amende encourue.

Le 2 novembre 2026, le ministère saoudien des Ressources humaines et du Développement social (HRSD) a rappelé aux établissements privés d'au moins 50 salariés que leurs données de formation de l'année doivent être déclarées sur Qiwa avant le 31 janvier 2027. Une entreprise qui n'a formé personne doit déclarer quand même, en indiquant qu'elle n'a pas formé. Un peu plus de la moitié seulement des établissements concernés ont déclaré pour 2025. Nous avons présenté l'annonce et l'amende dans notre actualité sur l'échéance du 31 janvier.

Ce tutoriel s'adresse à la personne qui doit produire les chiffres : le responsable du SIRH, le développeur interne, ou le consultant à qui l'on demande en janvier de « régler l'affaire de la formation sur Qiwa ». Le formulaire Qiwa se remplit à la main via le service Gestion de la formation : la difficulté n'est pas l'envoi. Elle est d'obtenir un chiffre défendable à partir d'un export de paie, d'une plateforme de formation et de trois tableurs tenus par trois managers différents.

Nous allons construire un outil qui fait exactement cela, et rien de plus :

  1. Décider si l'obligation s'applique, à partir de l'effectif au 31 décembre.
  2. Rejeter les lignes de formation erronées avant qu'elles n'entrent dans un total.
  3. Produire les chiffres par catégorie demandés par la décision : stagiaires ayant terminé, heures et coût.
  4. Signaler les stagiaires sous le minimum de huit unités.
  5. Vérifier l'objectif de 2 % de diplômés et de demandeurs d'emploi.
  6. Afficher l'amende encourue si rien n'est déclaré.

Ce que disent réellement les textes

Chaque règle du code ci-dessous vient de l'un des quatre textes du ministère. Là où les textes se taisent, le code prend un paramètre au lieu de deviner, et nous le disons.

RègleSourceDans le code
Les établissements d'au moins 50 salariés « à la fin de l'année » déclarent chaque année sur QiwaDécision ministérielle annoncée le 19 août 2023isCovered, effectif au 31 décembre
Déclarer les heures de formation et le nombre de stagiaires ayant terminé la formation, en quatre catégories : salariés, étudiants, diplômés, demandeurs d'emploiMême annoncebuildDisclosure ne compte que les formations terminées
La formation déclarée doit atteindre au moins huit unités par stagiaire et par anMême annonceminUnitsPerTrainee: 8, hoursPerUnit est un paramètre
Qui a formé déclare stagiaires, programmes, heures et coûts ; qui n'a pas formé déclare la non-réalisationAnnonce HRSD du 2 novembre 2026nil: true
Former des diplômés et demandeurs d'emploi à hauteur de 2 % de l'effectif totalMême annoncegraduateCheck
Défaut de déclaration : 5 000 SAR, catégorie A (50 salariés et plus) uniquementDécision ministérielle 112377, point 50nonFilingExposure
Montants antérieurs : 5 000 / 10 000 / 15 000 SAR selon la taille, doublés en cas de récidiveAnnonce HRSD du 10 novembre 2025renvoyés comme seconde lecture

Deux points que les textes ne tranchent pas, et que vous devez décider avant de déclarer :

  • Ce qu'est une « unité ». La décision de 2023 parle de huit unités, pas de huit heures. Le guide de procédure du ministère définit le terme, mais nous n'avons pu l'ouvrir depuis aucune source. L'outil compte par défaut une unité pour une heure, et en fait un seul paramètre à changer dès que vous avez le guide.
  • Comment arrondir les 2 %. Pour 120 salariés, 2 % font 2,4 personnes. L'annonce ne donne aucune règle d'arrondi. L'outil arrondit au supérieur, ce qui est le côté prudent.

Hors périmètre : le point 51 de la décision 112377 (500 SAR par salarié saoudien non formé selon les ratios fixés par le ministre). Ces ratios ne figurent pas dans les textes que nous avons lus, donc l'outil ne prétend pas les calculer.

Prérequis

  • Node.js 20 ou plus récent
  • Des bases de TypeScript
  • Un export du registre du personnel (identifiant, numéro unifié, dates d'entrée et de sortie)
  • Vos données de formation 2026, où qu'elles soient : plateforme de formation, grand livre pour les formations externes, feuilles de présence pour la formation sur le poste

Étape 1 : préparer le projet

mkdir qiwa-training && cd qiwa-training
npm init -y
npm pkg set type=module
npm install -D typescript tsx @types/node
mkdir src test

Ajoutez un tsconfig.json avec strict activé. Chaque fichier ci-dessous importe avec l'extension .ts, que tsx exécute directement.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true
  },
  "include": ["src", "test"]
}

Étape 2 : modéliser les données

Commencez par les types. Les quatre catégories forment une union fermée, car la décision en nomme exactement quatre. Le type Policy regroupe toutes les valeurs que le ministère ne nous a pas données sous une forme lisible, pour que personne n'ait à chercher une hypothèse cachée dans le code.

// The four trainee groups named in the 2023 ministerial decision.
export type TraineeCategory = "employee" | "student" | "graduate" | "jobSeeker";
 
export interface Worker {
  workerId: string;
  unifiedNumber: string; // the establishment's unified number (700...)
  startDate: string; // ISO date, first day on the establishment's register
  endDate?: string; // ISO date, last day; absent while still employed
}
 
export interface TrainingRecord {
  traineeId: string; // iqama / national ID, or an internal ID for non-employees
  category: TraineeCategory;
  programmeId: string;
  startDate: string; // ISO date
  endDate: string; // ISO date
  hours: number; // contact hours actually delivered
  costSar: number; // what the establishment paid, in riyals
  completed: boolean;
}
 
export interface Programme {
  programmeId: string;
  title: string;
  provider: string;
  internal: boolean; // on-the-job or in-house, versus an external provider
}
 
// Settings the Ministry has not published in a form we could read.
// Keep them explicit so a reviewer can see every assumption.
export interface Policy {
  year: number; // the training year being disclosed
  hoursPerUnit: number; // how many hours count as one "training unit"
  minUnitsPerTrainee: number; // 8 in the 2023 decision
  graduateRate: number; // 0.02 per the 2 November 2026 notice
}

Quelques choix de modélisation à expliciter :

  • hours désigne les heures réalisées, pas planifiées. Si quelqu'un a quitté une formation de cinq jours au deuxième jour, enregistrez deux jours.
  • costSar est ce que l'établissement a payé. Une formation interne sans facture vaut 0, pas une valeur manquante. Un coût manquant est un problème de données ; un coût nul est un fait.
  • Étudiants, diplômés et demandeurs d'emploi ne sont pas dans la paie, donc traineeId est un texte libre. Utilisez le numéro d'identité ou d'iqama quand vous l'avez, pour ne jamais compter une personne deux fois.

Étape 3 : l'obligation s'applique-t-elle ?

La décision de 2023 vise les établissements d'au moins 50 salariés « à la fin de l'année en cours ». Nous lisons cela comme le registre au 31 décembre de l'année de formation. La décision 112377 compte les salariés sous le numéro unifié de l'établissement : le décompte se fait donc par numéro unifié, pas par registre de commerce.

import type { Worker } from "./types.ts";
 
export const COVERAGE_THRESHOLD = 50;
 
// A worker counts if they were on the register on the given day.
function onRegister(worker: Worker, day: string): boolean {
  if (worker.startDate > day) return false;
  if (worker.endDate !== undefined && worker.endDate < day) return false;
  return true;
}
 
// Head count for one unified number on one day, each worker counted once
// even if they appear twice (for example after a transfer between branches).
export function headcountOn(
  workers: Worker[],
  unifiedNumber: string,
  day: string,
): number {
  const ids = new Set<string>();
  for (const w of workers) {
    if (w.unifiedNumber === unifiedNumber && onRegister(w, day)) {
      ids.add(w.workerId);
    }
  }
  return ids.size;
}
 
// The 2023 decision tests the head count "at the end of the year".
// We read that as 31 December of the training year.
export function isCovered(
  workers: Worker[],
  unifiedNumber: string,
  year: number,
): { covered: boolean; headcount: number; asOf: string } {
  const asOf = `${year}-12-31`;
  const headcount = headcountOn(workers, unifiedNumber, asOf);
  return { covered: headcount >= COVERAGE_THRESHOLD, headcount, asOf };
}

Deux pièges sont couverts. Un salarié présent deux fois dans l'export, ce qui arrive après un transfert entre succursales, compte une seule fois. Et un salarié dont le contrat s'est terminé le 30 décembre ne compte pas au 31.

Si vous êtes proche de 50, comparez avec le chiffre affiché dans Qiwa avant de vous y fier. C'est le registre du ministère qui sera contrôlé, pas le vôtre.

Étape 4 : valider avant de totaliser

La plupart des déclarations fausses ne viennent pas d'une erreur de calcul. Ce sont une formation de janvier 2027 comptée en 2026, une ligne saisie deux fois par deux managers, ou un code de programme que personne ne sait expliquer. Contrôlez d'abord les lignes, et signalez chaque problème au lieu d'écarter la ligne en silence.

import type { Programme, TrainingRecord } from "./types.ts";
 
export interface Problem {
  index: number; // row in the input, starting at 0
  code:
    | "OUTSIDE_YEAR"
    | "BAD_DATES"
    | "BAD_HOURS"
    | "BAD_COST"
    | "UNKNOWN_PROGRAMME"
    | "DUPLICATE";
  message: string;
}
 
// Row-level checks to run before anything is typed into Qiwa.
// A row with a problem is reported, never silently dropped.
export function validateRecords(
  records: TrainingRecord[],
  programmes: Programme[],
  year: number,
): Problem[] {
  const problems: Problem[] = [];
  const known = new Set(programmes.map((p) => p.programmeId));
  const seen = new Map<string, number>();
  const first = `${year}-01-01`;
  const last = `${year}-12-31`;
 
  records.forEach((r, index) => {
    if (r.endDate < r.startDate) {
      problems.push({ index, code: "BAD_DATES", message: "ends before it starts" });
    }
    if (r.endDate < first || r.endDate > last) {
      problems.push({
        index,
        code: "OUTSIDE_YEAR",
        message: `ended ${r.endDate}, outside ${year}`,
      });
    }
    if (!Number.isFinite(r.hours) || r.hours <= 0) {
      problems.push({ index, code: "BAD_HOURS", message: "hours must be above zero" });
    }
    if (!Number.isFinite(r.costSar) || r.costSar < 0) {
      problems.push({ index, code: "BAD_COST", message: "cost cannot be negative" });
    }
    if (!known.has(r.programmeId)) {
      problems.push({
        index,
        code: "UNKNOWN_PROGRAMME",
        message: `programme ${r.programmeId} is not in the catalogue`,
      });
    }
    const key = `${r.traineeId}|${r.programmeId}|${r.startDate}`;
    const prior = seen.get(key);
    if (prior !== undefined) {
      problems.push({
        index,
        code: "DUPLICATE",
        message: `same trainee, programme and start date as row ${prior}`,
      });
    } else {
      seen.set(key, index);
    }
  });
 
  return problems;
}

Une formation appartient à l'année où elle se termine. C'est notre convention, choisie parce que la décision compte les stagiaires ayant terminé. Une formation du 20 décembre 2026 au 2 janvier 2027 relève de la déclaration 2027, et le validateur la signale si elle apparaît en 2026.

Étape 5 : produire les chiffres de la déclaration

Passons aux totaux. Seules les formations terminées comptent. Chaque personne compte une fois par catégorie, quel que soit le nombre de formations suivies, tandis que les heures et les coûts s'additionnent sur toutes.

import type { Policy, TraineeCategory, TrainingRecord } from "./types.ts";
 
const CATEGORIES: TraineeCategory[] = ["employee", "student", "graduate", "jobSeeker"];
 
export interface CategoryTotals {
  completedTrainees: number; // distinct people with at least one completed course
  hours: number;
  costSar: number;
}
 
export interface Disclosure {
  year: number;
  nil: boolean; // true when there is nothing to report: file the nil declaration
  programmes: number;
  byCategory: Record<TraineeCategory, CategoryTotals>;
  totalHours: number;
  totalCostSar: number;
  belowMinimum: { traineeId: string; units: number }[];
}
 
function emptyTotals(): CategoryTotals {
  return { completedTrainees: 0, hours: 0, costSar: 0 };
}
 
// Rounds to halalas so floating point never shows up in a filing.
function sar(n: number): number {
  return Math.round(n * 100) / 100;
}
 
// Builds the year's figures from records that already passed validation.
// Only completed courses count: the decision asks for trainees
// "who completed training".
export function buildDisclosure(records: TrainingRecord[], policy: Policy): Disclosure {
  const done = records.filter((r) => r.completed);
 
  const byCategory = {} as Record<TraineeCategory, CategoryTotals>;
  for (const c of CATEGORIES) byCategory[c] = emptyTotals();
 
  const people = new Map<TraineeCategory, Set<string>>();
  const hoursPerTrainee = new Map<string, number>();
  const programmes = new Set<string>();
 
  for (const r of done) {
    const t = byCategory[r.category];
    t.hours += r.hours;
    t.costSar = sar(t.costSar + r.costSar);
    if (!people.has(r.category)) people.set(r.category, new Set());
    people.get(r.category)!.add(r.traineeId);
    hoursPerTrainee.set(r.traineeId, (hoursPerTrainee.get(r.traineeId) ?? 0) + r.hours);
    programmes.add(r.programmeId);
  }
  for (const c of CATEGORIES) byCategory[c].completedTrainees = people.get(c)?.size ?? 0;
 
  const belowMinimum: { traineeId: string; units: number }[] = [];
  for (const [traineeId, hours] of hoursPerTrainee) {
    const units = hours / policy.hoursPerUnit;
    if (units < policy.minUnitsPerTrainee) {
      belowMinimum.push({ traineeId, units: Math.round(units * 100) / 100 });
    }
  }
  belowMinimum.sort((a, b) => a.traineeId.localeCompare(b.traineeId));
 
  const totalHours = CATEGORIES.reduce((s, c) => s + byCategory[c].hours, 0);
  const totalCostSar = sar(CATEGORIES.reduce((s, c) => s + byCategory[c].costSar, 0));
 
  return {
    year: policy.year,
    nil: done.length === 0,
    programmes: programmes.size,
    byCategory,
    totalHours,
    totalCostSar,
    belowMinimum,
  };
}

La liste belowMinimum est le contrôle des huit unités. Elle ne retire personne des totaux : un stagiaire de six heures a bien terminé une formation. Elle indique qui a besoin d'une formation de plus avant de pouvoir affirmer que l'établissement atteint le minimum, et c'est la liste que votre responsable formation voudra voir en décembre plutôt qu'en février.

Changez hoursPerUnit pour voir pourquoi ce paramètre compte. À une heure par unité, un stagiaire de neuf heures passe. À quatre heures par unité (une demi-journée), le même stagiaire n'a que 2,25 unités et échoue. Tant que vous n'avez pas lu le guide de procédure, montrez les deux résultats à la personne qui signe la déclaration.

Si rien n'a été terminé, nil vaut true. C'est le résultat que le ministère tient le plus à voir déclaré, et celui que les entreprises sautent le plus souvent : l'annonce de novembre dit clairement qu'un établissement sans formation doit tout de même le déclarer.

Étape 6 : les diplômés et l'amende encourue

Deux derniers chiffres. Le premier est l'objectif de 2 % de diplômés et demandeurs d'emploi cité par l'annonce de novembre. Le second est le coût d'une absence de déclaration.

import type { Disclosure } from "./aggregate.ts";
import type { Policy } from "./types.ts";
 
export interface GraduateCheck {
  required: number; // people, rounded up: our conservative reading
  trained: number; // graduates plus job seekers who completed training
  gap: number;
}
 
// The 2 November 2026 notice cites a decision to train graduates and
// job seekers at 2% of total workers. It does not say how to round, so
// we round up: a firm of 120 needs 3, not 2.4.
export function graduateCheck(
  d: Disclosure,
  headcount: number,
  policy: Policy,
): GraduateCheck {
  const required = Math.ceil(headcount * policy.graduateRate);
  const trained =
    d.byCategory.graduate.completedTrainees + d.byCategory.jobSeeker.completedTrainees;
  return { required, trained, gap: Math.max(0, required - trained) };
}
 
export interface FineReading {
  source: string;
  amountSar: number;
}
 
// What a covered firm risks if it files nothing. Two Ministry figures exist,
// so we return both and let a person choose which one to budget on.
export function nonFilingExposure(headcount: number): FineReading[] {
  if (headcount < 50) return [];
  const tiered = headcount < 500 ? 5_000 : headcount <= 3_000 ? 10_000 : 15_000;
  return [
    { source: "Decision 112377, item 50 (schedule in force)", amountSar: 5_000 },
    { source: "HRSD notice of 10 Nov 2025, first offence", amountSar: tiered },
  ];
}

Sur l'amende, les chiffres publiés divergent. L'annonce du 10 novembre 2025 fixait 5 000, 10 000 ou 15 000 SAR selon la taille, doublés en cas de récidive. La décision 112377 du 21/8/1447H (9 février 2026) a remplacé tout le barème des infractions. Son point 50 fixe 5 000 SAR pour la catégorie A et rien au-dessus, et sa clause 9 abroge ce qui la contredit. Notre lecture est que le barème 2026 s'applique. Nous n'avons trouvé aucune déclaration du ministère qui le dise directement, donc l'outil affiche les deux montants et laisse la décision budgétaire à une personne. Notez aussi que les tranches de 2025 ne classent pas un établissement d'exactement 3 000 salariés ; le code le place dans la tranche du milieu.

Une première infraction peut aussi bénéficier de la réduction de 80 % prévue par la décision 93442. Notre guide du barème de la décision 112377 en détaille les conditions.

Étape 7 : l'exécuter sur vos données

Placez les quatre entrées dans un seul fichier JSON. La liste des salariés est vide ici pour garder la page courte ; collez-y l'export complet du registre, un objet par salarié comme dans le type Worker.

{
  "unifiedNumber": "7001234567",
  "workers": [],
  "programmes": [
    { "programmeId": "P1", "title": "Excel for finance", "provider": "in-house", "internal": true },
    { "programmeId": "P2", "title": "Fire safety", "provider": "Riyadh Safety Institute", "internal": false }
  ],
  "records": [
    { "traineeId": "1012345678", "category": "employee", "programmeId": "P1", "startDate": "2026-03-01", "endDate": "2026-03-05", "hours": 10, "costSar": 0, "completed": true },
    { "traineeId": "1012345678", "category": "employee", "programmeId": "P2", "startDate": "2026-09-14", "endDate": "2026-09-14", "hours": 6, "costSar": 450, "completed": true },
    { "traineeId": "2098765432", "category": "employee", "programmeId": "P2", "startDate": "2026-09-14", "endDate": "2026-09-14", "hours": 6, "costSar": 450, "completed": true },
    { "traineeId": "G-0001", "category": "graduate", "programmeId": "P1", "startDate": "2026-07-01", "endDate": "2026-08-31", "hours": 120, "costSar": 3000, "completed": true }
  ]
}

Le script d'exécution relie les morceaux. Il s'arrête avec le code de sortie 2 au moindre problème de données : vous pouvez donc le lancer depuis une tâche planifiée et recevoir une alerte au lieu de déclarer une demi-année.

import { readFileSync } from "node:fs";
import { buildDisclosure } from "./aggregate.ts";
import { isCovered } from "./coverage.ts";
import { graduateCheck, nonFilingExposure } from "./exposure.ts";
import type { Policy, Programme, TrainingRecord, Worker } from "./types.ts";
import { validateRecords } from "./validate.ts";
 
interface Input {
  unifiedNumber: string;
  workers: Worker[];
  programmes: Programme[];
  records: TrainingRecord[];
}
 
const policy: Policy = {
  year: 2026,
  hoursPerUnit: 1, // ASSUMPTION: replace with the Ministry's definition
  minUnitsPerTrainee: 8,
  graduateRate: 0.02,
};
 
const path = process.argv[2];
if (!path) {
  console.error("usage: npx tsx src/report.ts training-2026.json");
  process.exit(1);
}
const input = JSON.parse(readFileSync(path, "utf8")) as Input;
 
const cover = isCovered(input.workers, input.unifiedNumber, policy.year);
console.log(`Head count on ${cover.asOf}: ${cover.headcount}`);
if (!cover.covered) {
  console.log("Below 50 workers: the disclosure duty does not apply.");
  process.exit(0);
}
 
const problems = validateRecords(input.records, input.programmes, policy.year);
if (problems.length > 0) {
  for (const p of problems) console.log(`row ${p.index}: ${p.code} ${p.message}`);
  console.log(`${problems.length} problem(s). Fix the source data, then run again.`);
  process.exit(2);
}
 
const d = buildDisclosure(input.records, policy);
if (d.nil) {
  console.log("No completed training: file the nil declaration in Qiwa.");
} else {
  console.log(`Programmes: ${d.programmes}`);
  for (const [category, t] of Object.entries(d.byCategory)) {
    console.log(
      `${category.padEnd(10)} trainees ${t.completedTrainees}  hours ${t.hours}  cost SAR ${t.costSar}`,
    );
  }
  console.log(`Total hours ${d.totalHours}, total cost SAR ${d.totalCostSar}`);
  for (const b of d.belowMinimum) {
    console.log(`below minimum: ${b.traineeId} has ${b.units} unit(s)`);
  }
}
 
const g = graduateCheck(d, cover.headcount, policy);
console.log(`Graduates and job seekers: ${g.trained} of ${g.required} (gap ${g.gap})`);
for (const f of nonFilingExposure(cover.headcount)) {
  console.log(`If not filed: SAR ${f.amountSar} per ${f.source}`);
}

Avec 120 salariés au registre et les quatre lignes d'exemple, la sortie est :

Head count on 2026-12-31: 120
Programmes: 2
employee   trainees 2  hours 22  cost SAR 900
student    trainees 0  hours 0  cost SAR 0
graduate   trainees 1  hours 120  cost SAR 3000
jobSeeker  trainees 0  hours 0  cost SAR 0
Total hours 142, total cost SAR 3900
below minimum: 2098765432 has 6 unit(s)
Graduates and job seekers: 1 of 3 (gap 2)
If not filed: SAR 5000 per Decision 112377, item 50 (schedule in force)
If not filed: SAR 5000 per HRSD notice of 10 Nov 2025, first offence

Lisez-la comme une liste de tâches. Un salarié a besoin de deux heures de formation documentée en plus, il manque deux diplômés pour atteindre les 2 %, et les chiffres par catégorie sont ceux à saisir dans Gestion de la formation.

Étape 8 : tester les règles

Chaque règle du tableau du début a son test, y compris les limites : 49 contre 50 salariés, un contrat qui se termine la veille de la fin d'année, le paramètre de taille d'unité et l'arrondi des 2 %.

import assert from "node:assert/strict";
import { test } from "node:test";
import { buildDisclosure } from "../src/aggregate.ts";
import { headcountOn, isCovered } from "../src/coverage.ts";
import { graduateCheck, nonFilingExposure } from "../src/exposure.ts";
import type { Policy, Programme, TrainingRecord, Worker } from "../src/types.ts";
import { validateRecords } from "../src/validate.ts";
 
const policy: Policy = { year: 2026, hoursPerUnit: 1, minUnitsPerTrainee: 8, graduateRate: 0.02 };
 
function staff(n: number, unifiedNumber = "7001"): Worker[] {
  return Array.from({ length: n }, (_, i) => ({
    workerId: `W${i}`,
    unifiedNumber,
    startDate: "2025-01-01",
  }));
}
 
const catalogue: Programme[] = [
  { programmeId: "P1", title: "Excel for finance", provider: "in-house", internal: true },
  { programmeId: "P2", title: "Safety", provider: "External Co", internal: false },
];
 
function rec(over: Partial<TrainingRecord>): TrainingRecord {
  return {
    traineeId: "T1",
    category: "employee",
    programmeId: "P1",
    startDate: "2026-03-01",
    endDate: "2026-03-05",
    hours: 10,
    costSar: 1000,
    completed: true,
    ...over,
  };
}
 
test("coverage starts at 50 workers on 31 December", () => {
  assert.equal(isCovered(staff(49), "7001", 2026).covered, false);
  assert.equal(isCovered(staff(50), "7001", 2026).covered, true);
});
 
test("a worker who left before year end is not counted", () => {
  const w = staff(50);
  w[0] = { ...w[0], endDate: "2026-12-30" };
  assert.equal(headcountOn(w, "7001", "2026-12-31"), 49);
});
 
test("the same worker listed twice counts once", () => {
  const w = [...staff(50), { workerId: "W0", unifiedNumber: "7001", startDate: "2026-06-01" }];
  assert.equal(headcountOn(w, "7001", "2026-12-31"), 50);
});
 
test("other unified numbers are ignored", () => {
  const w = [...staff(30), ...staff(30, "7002")];
  assert.equal(headcountOn(w, "7001", "2026-12-31"), 30);
});
 
test("validation reports every bad row", () => {
  const problems = validateRecords(
    [
      rec({}),
      rec({ endDate: "2027-01-02", startDate: "2026-12-20" }),
      rec({ hours: 0, startDate: "2026-04-01", endDate: "2026-04-01" }),
      rec({ programmeId: "P9", startDate: "2026-05-01", endDate: "2026-05-01" }),
      rec({}),
    ],
    catalogue,
    2026,
  );
  assert.deepEqual(
    problems.map((p) => [p.index, p.code]),
    [
      [1, "OUTSIDE_YEAR"],
      [2, "BAD_HOURS"],
      [3, "UNKNOWN_PROGRAMME"],
      [4, "DUPLICATE"],
    ],
  );
});
 
test("no completed training means a nil declaration", () => {
  const d = buildDisclosure([rec({ completed: false })], policy);
  assert.equal(d.nil, true);
  assert.equal(d.totalHours, 0);
});
 
test("trainees are counted once per category, hours and cost summed", () => {
  const d = buildDisclosure(
    [
      rec({}),
      rec({ programmeId: "P2", hours: 6, costSar: 450.5 }),
      rec({ traineeId: "G1", category: "graduate", hours: 40, costSar: 0 }),
    ],
    policy,
  );
  assert.equal(d.byCategory.employee.completedTrainees, 1);
  assert.equal(d.byCategory.employee.hours, 16);
  assert.equal(d.byCategory.employee.costSar, 1450.5);
  assert.equal(d.byCategory.graduate.completedTrainees, 1);
  assert.equal(d.programmes, 2);
  assert.equal(d.totalCostSar, 1450.5);
});
 
test("trainees under eight units are flagged, and the unit size matters", () => {
  const records = [rec({ hours: 6 }), rec({ traineeId: "T2", hours: 9 })];
  assert.deepEqual(buildDisclosure(records, policy).belowMinimum, [{ traineeId: "T1", units: 6 }]);
  const halfDays = buildDisclosure(records, { ...policy, hoursPerUnit: 4 });
  assert.deepEqual(
    halfDays.belowMinimum.map((b) => b.traineeId),
    ["T1", "T2"],
  );
});
 
test("graduate target rounds up", () => {
  const d = buildDisclosure([rec({ traineeId: "G1", category: "graduate" })], policy);
  assert.deepEqual(graduateCheck(d, 120, policy), { required: 3, trained: 1, gap: 2 });
  assert.equal(graduateCheck(d, 50, policy).required, 1);
});
 
test("both fine readings are returned for covered firms only", () => {
  assert.deepEqual(nonFilingExposure(49), []);
  assert.deepEqual(
    nonFilingExposure(600).map((f) => f.amountSar),
    [5_000, 10_000],
  );
  assert.equal(nonFilingExposure(3_001)[1].amountSar, 15_000);
  assert.equal(nonFilingExposure(499)[1].amountSar, 5_000);
});

Lancez-les avec :

npx tsx --test test/disclosure.test.ts

Les dix doivent passer. Si vous changez une lecture, par exemple la taille d'unité ou l'arrondi, modifiez d'abord le test, pour que la décision soit écrite là où un relecteur la verra.

Dépannage

L'effectif ne correspond pas à Qiwa. L'export inclut sans doute des salariés rattachés à un autre numéro unifié, ou il manque des dates de sortie. Comparez les identifiants, pas seulement les totaux.

Les heures paraissent trop élevées. Cherchez des formations saisies une fois par séance au lieu d'une fois par formation. Le contrôle de doublon ne repère que le même stagiaire, le même programme et la même date de début.

Aucun diplômé n'apparaît. Les étudiants en formation coopérative et les diplômés stagiaires sont souvent suivis par une autre équipe, hors du SIRH. Ce sont des catégories distinctes dans la décision : demandez cette liste explicitement.

Des coûts manquent pour les formations internes. Saisissez 0 et notez le temps du formateur si vous voulez le déclarer plus tard. Ne laissez pas le champ vide.

Pour aller plus loin

Conclusion

La déclaration de formation est une démarche modeste, avec une échéance fixe et une amende connue. Ce qui la rend difficile, ce sont les données derrière : une année de formation répartie entre des systèmes qui n'ont jamais été faits pour concorder. Un outil de quelques centaines de lignes, avec les règles du ministère en tests et ses zones grises en paramètres nommés, vous donne des chiffres défendables et la liste de ce qu'il faut corriger avant le 31 janvier.

Si vos données en sont là et que vous voulez un second regard avant l'échéance, décrivez-nous brièvement vos systèmes. Nous vous dirons d'où viendront les chiffres et ce qui manque.