Tous les guides sur Saber décrivent le même parcours : connectez-vous à la plateforme, enregistrez votre établissement, choisissez un organisme d'évaluation de la conformité, saisissez les données produit, payez, recevez le certificat. Tous sont écrits pour quelqu'un qui clique dans un portail, un produit à la fois.
Aucun n'est écrit pour celui qui détient un catalogue de quatre mille références, exporte vers l'Arabie Saoudite chaque mois, et voit un pourcentage prévisible de demandes de certificat d'expédition revenir rejetées — sans savoir lesquelles des quatre mille échoueront, jusqu'à ce que le conteneur soit déjà au port islamique de Djeddah avec les surestaries qui courent.
Cet écart existe à cause d'un fait structurel abordé dans pourquoi vos cargaisons bloquent au port : il n'existe aucune API de soumission en masse pour Saber. Vous ne pouvez pas pousser quatre mille produits par programmation et récupérer quatre mille verdicts. Saber est, par conception, une plateforme opérée manuellement.
Cette contrainte n'est pas une impasse. C'est la spécification complète de ce qu'il faut construire à la place. Si vous ne pouvez pas demander à la plateforme si vos données seront acceptées, vous construisez ce qui répond à cette question localement — avant soumission, sur votre propre catalogue, en masse.
Ce tutoriel construit exactement cela.
Prérequis
Avant de commencer, assurez-vous de disposer de :
- Node.js 20+ et npm
- Les fondamentaux TypeScript — interfaces, génériques, unions discriminées
- Une connaissance pratique de Zod ou d'un validateur de schémas équivalent (nous utiliserons Zod v4)
- L'accès à votre catalogue produits sous une forme structurée (CSV, export de base de données, extraction ERP)
- Une familiarité avec le certificat de conformité produit (PCoC) et le certificat de conformité d'expédition (SCoC) — l'article ci-dessus les explique si besoin
Vous n'avez pas besoin d'identifiants Saber pour suivre ce tutoriel. C'est précisément le point : tout ici s'exécute sur vos propres données, plus les données de référence publiques de la SASO.
Ce que vous allez construire
Un outil en ligne de commande qui prend un catalogue produits et produit un rapport de rejet trié. Concrètement, il va :
- Normaliser des lignes de catalogue désordonnées en un enregistrement produit canonique
- Valider les codes SH selon la structure tarifaire saoudienne à 12 chiffres — pas celle à 6 chiffres de l'international
- Résoudre les réglementations techniques de chaque code SH depuis une table de référence locale, pour savoir quels produits nécessitent réellement une évaluation de conformité
- Contrôler la couverture et l'expiration des certificats avec un délai configurable, afin qu'un certificat expirant en cours de transit soit signalé avant l'expédition
- Rapprocher les lignes d'expédition des produits enregistrés, d'où provient en réalité la majorité des rejets de SCoC
- Émettre un rapport priorisé groupé par facilité de correction, et non par numéro de ligne
Le résultat est une liste que votre équipe conformité traite en un après-midi, au lieu d'un rejet découvert six semaines plus tard dans un port.
Voici l'architecture visée :
catalogue.csv ──► normalise ──► ProductRecord[]
│
┌────────────────┼────────────────┐
▼ ▼ ▼
hs-code rules regulation map certificate ledger
│ │ │
└────────────────┼────────────────┘
▼
ValidationIssue[]
▼
triaged rejection report
Étape 1 : Initialisation du projet
Créez le projet et installez les dépendances.
mkdir saber-validator && cd saber-validator
npm init -y
npm install zod csv-parse date-fns
npm install -D typescript tsx @types/node vitest
npx tsc --initConfigurez tsconfig.json pour une cible Node moderne :
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}Ajoutez "type": "module" au package.json ainsi que quelques scripts :
{
"type": "module",
"scripts": {
"validate": "tsx src/cli.ts",
"test": "vitest run"
}
}Créez l'arborescence :
mkdir -p src/{rules,data,report} testsÀ propos de
noUncheckedIndexedAccess. Son activation est délibérée. Ce code fait beaucoup de recherches par clé dans des tables de référence, et la différence entre « ce code SH n'a aucune réglementation » et « ce code renvoie undefined à cause d'une faute de frappe » est exactement la classe de bug qui expédie un mauvais catalogue. Laissez le compilateur vous forcer à traiter le cas manquant.
Étape 2 : Modéliser l'enregistrement produit canonique
Tout ce qui suit dépend d'une forme unique bien définie. Les vrais catalogues arrivent en tableurs avec des noms de colonnes incohérents, un mélange d'arabe et d'anglais, des espaces parasites, et des codes SH stockés comme nombres dont Excel a mangé le zéro initial.
Définissez d'abord l'enregistrement canonique, puis écrivez les adaptateurs vers lui.
// src/types.ts
import { z } from "zod";
export const ProductRecordSchema = z.object({
/** Votre référence interne — la clé de jointure de tout le reste */
sku: z.string().min(1),
/** Nom du produit tel qu'il figurera sur le certificat et la facture */
nameEn: z.string().min(1),
nameAr: z.string().optional(),
/** Marque et modèle doivent correspondre aux marchandises et à la facture */
brand: z.string().min(1),
model: z.string().min(1),
/** Code tarifaire saoudien à 12 chiffres, en chaîne pour garder les zéros initiaux */
hsCode: z.string(),
/** Pays de fabrication, ISO 3166-1 alpha-2 */
countryOfOrigin: z.string().length(2),
/** Raison sociale du fabricant, telle qu'imprimée sur le rapport d'essai */
manufacturer: z.string().min(1),
/** Référence du PCoC, s'il a déjà été délivré */
pcocNumber: z.string().optional(),
pcocExpiry: z.coerce.date().optional(),
});
export type ProductRecord = z.infer<typeof ProductRecordSchema>;Vient ensuite le type d'anomalie. C'est la décision de conception la plus importante du projet, elle mérite qu'on s'y attarde.
// src/types.ts (suite)
export type Severity = "blocker" | "warning" | "info";
/**
* La facilité de correction pilote le regroupement du rapport. Un responsable
* conformité ne veut pas des anomalies triées par numéro de ligne — il veut
* savoir ce qu'il peut corriger aujourd'hui par rapport à ce qui exige un
* nouveau rapport d'essai et six semaines de délai.
*/
export type Fixability =
| "data-entry" // correction dans votre propre système, quelques minutes
| "documentation" // demander un document au fournisseur, quelques jours
| "certification"; // nouvelle évaluation de conformité, plusieurs semaines
export interface ValidationIssue {
sku: string;
code: string;
severity: Severity;
fixability: Fixability;
message: string;
/** La valeur observée, pour que le rapport soit exploitable sans ouvrir le fichier source */
observed?: string;
/** Ce à quoi elle devrait ressembler */
expected?: string;
}Regrouper par facilité de correction plutôt que par gravité est ce qui fait qu'un tel outil est réellement utilisé, au lieu d'être généré une fois puis ignoré. Deux bloquants ne sont pas équivalents si l'un est une faute de frappe et l'autre un rapport d'essai manquant.
Étape 3 : Valider le code tarifaire à 12 chiffres
Voici la règle que la plupart des catalogues manquent. Le code du Système Harmonisé international fait six chiffres. L'Arabie Saoudite — et le tarif douanier unifié du CCG — l'étend à douze. Saber résout les réglementations techniques au niveau des 12 chiffres complets : un catalogue portant des codes à 6 ou 8 chiffres n'est donc pas seulement imprécis, il est irrésolvable.
La structure se décompose ainsi :
| Chiffres | Signification |
|---|---|
| 1–2 | Chapitre |
| 3–4 | Position |
| 5–6 | Sous-position (fin du code SH international) |
| 7–8 | Subdivision du tarif unifié CCG |
| 9–12 | Subdivision statistique nationale |
Écrivez le validateur :
// src/rules/hs-code.ts
import type { ProductRecord, ValidationIssue } from "../types.js";
const DIGITS_ONLY = /^\d+$/;
export function validateHsCode(product: ProductRecord): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const raw = product.hsCode.trim();
// Excel est l'ennemi ici. Les codes arrivent en "8516.60.00" ou "851660",
// ou en flottant ayant perdu son zéro initial.
const normalised = raw.replace(/[.\s-]/g, "");
if (!DIGITS_ONLY.test(normalised)) {
issues.push({
sku: product.sku,
code: "HS_NON_NUMERIC",
severity: "blocker",
fixability: "data-entry",
message: "Le code SH contient des caractères non numériques après normalisation.",
observed: raw,
expected: "12 chiffres, ex. 851660100000",
});
return issues; // Inutile de tester la longueur sur une valeur corrompue
}
if (normalised.length === 12) {
return issues; // Correct
}
if (normalised.length < 12) {
// Le cas courant : un code international à 6 ou 8 chiffres a été importé
// et personne ne l'a étendu au niveau national saoudien.
issues.push({
sku: product.sku,
code: "HS_TOO_SHORT",
severity: "blocker",
fixability: "data-entry",
message:
`Le code SH comporte ${normalised.length} chiffres. Saber résout les ` +
"réglementations à 12 chiffres ; un code plus court ne peut être rattaché.",
observed: normalised,
expected: `${normalised.padEnd(12, "0")} (à vérifier — ne complétez pas à l'aveugle)`,
});
} else {
issues.push({
sku: product.sku,
code: "HS_TOO_LONG",
severity: "blocker",
fixability: "data-entry",
message: `Le code SH comporte ${normalised.length} chiffres, 12 attendus.`,
observed: normalised,
});
}
return issues;
}Ne complétez pas automatiquement par des zéros. Le champ
expectedci-dessus propose une valeur complétée à titre d'indication pour un humain, et le message le dit. Les chiffres de subdivision nationale portent du sens — compléter un code à 8 chiffres jusqu'à 12 avec des zéros peut silencieusement désigner une autre classe de produits soumise à d'autres exigences réglementaires. Le rôle du validateur est de faire apparaître l'écart, pas de le contourner par une supposition.
Étape 4 : Résoudre les réglementations techniques
Qu'un code SH soit bien formé ne dit rien sur la nécessité d'un certificat. Cela dépend de la réglementation technique saoudienne qui couvre ce code.
La SASO publie cette correspondance. La liste de codes SH consultable se trouve sur saber.sa/home/hscodes, et la SASO expose des API Open Data pour ses jeux de données publiés. Construisez une table de référence locale à partir de ces sources, rafraîchie de façon planifiée — jamais au moment de la validation.
// src/data/regulations.ts
export interface RegulationEntry {
/** Code à 12 chiffres, ou préfixe pour une correspondance par plage */
hsPrefix: string;
regulationCode: string;
regulationNameEn: string;
/** Cette réglementation exige-t-elle un PCoC avant l'émission d'un SCoC ? */
requiresPcoc: boolean;
/** Les catégories à haut risque attirent plus de contrôles et des délais plus longs */
riskLevel: "low" | "medium" | "high";
}
/**
* Sous-ensemble illustratif. Alimentez la vraie table depuis les données
* publiées par la SASO et versionnez-la — les réglementations changent, et
* vous voulez savoir contre quel jeu de règles une validation passée a tourné.
*/
export const REGULATIONS: RegulationEntry[] = [
{
hsPrefix: "8516",
regulationCode: "SASO-TR-ELEC-01",
regulationNameEn: "Technical Regulation for Low Voltage Electrical Equipment",
requiresPcoc: true,
riskLevel: "high",
},
{
hsPrefix: "9503",
regulationCode: "SASO-TR-TOYS-01",
regulationNameEn: "Technical Regulation for Toys",
requiresPcoc: true,
riskLevel: "high",
},
{
hsPrefix: "6109",
regulationCode: "SASO-TR-TEX-01",
regulationNameEn: "Technical Regulation for Textile Products",
requiresPcoc: true,
riskLevel: "medium",
},
];La correspondance par préfixe le plus long est la bonne stratégie de recherche, car les réglementations sont définies à des niveaux de granularité variables — une règle peut couvrir un chapitre entier, ou une seule ligne à 12 chiffres.
// src/rules/regulation.ts
import { REGULATIONS, type RegulationEntry } from "../data/regulations.js";
import type { ProductRecord, ValidationIssue } from "../types.js";
export function resolveRegulation(hsCode: string): RegulationEntry | undefined {
const normalised = hsCode.replace(/[.\s-]/g, "");
// Le préfixe le plus long gagne : une règle à 12 chiffres prime sur un chapitre à 4.
let best: RegulationEntry | undefined;
for (const entry of REGULATIONS) {
if (!normalised.startsWith(entry.hsPrefix)) continue;
if (!best || entry.hsPrefix.length > best.hsPrefix.length) {
best = entry;
}
}
return best;
}
export function validateRegulationCoverage(
product: ProductRecord,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const regulation = resolveRegulation(product.hsCode);
if (!regulation) {
// Des produits réellement non réglementés existent. Mais un code sans
// correspondance est bien plus souvent un mauvais code qu'un produit
// véritablement exempté — donc on avertit, on ne laisse pas passer.
issues.push({
sku: product.sku,
code: "REG_UNMATCHED",
severity: "warning",
fixability: "data-entry",
message:
"Aucune réglementation technique ne correspond à ce code SH. Confirmez " +
"que le produit est réellement exempté et non mal classé avant expédition.",
observed: product.hsCode,
});
return issues;
}
if (regulation.requiresPcoc && !product.pcocNumber) {
issues.push({
sku: product.sku,
code: "PCOC_MISSING",
severity: "blocker",
// La coûteuse : elle nécessite un organisme d'évaluation, des rapports
// d'essai, et plusieurs semaines de délai calendaire.
fixability: "certification",
message:
`${regulation.regulationNameEn} (${regulation.regulationCode}) exige un ` +
"certificat de conformité produit. Aucun PCoC n'est enregistré pour cette référence.",
});
}
if (regulation.riskLevel === "high") {
issues.push({
sku: product.sku,
code: "REG_HIGH_RISK",
severity: "info",
fixability: "documentation",
message:
`Couvert par une réglementation à haut risque (${regulation.regulationCode}). ` +
"Attendez-vous à des contrôles supplémentaires et des délais plus longs.",
});
}
return issues;
}Étape 5 : Expiration des certificats et délai de transit
Un PCoC valide aujourd'hui et expirant dans dix-huit jours pose problème si votre fret maritime en prend vingt-huit. Le contrôle naïf — la date d'expiration est-elle postérieure à aujourd'hui — le laisse passer, et la demande de certificat d'expédition échoue une fois les marchandises parties.
Validez contre la date à laquelle le certificat doit encore être valide, pas contre aujourd'hui.
// src/rules/certificate.ts
import { addDays, differenceInDays, isBefore } from "date-fns";
import type { ProductRecord, ValidationIssue } from "../types.js";
export interface ExpiryOptions {
/** Jours entre la validation et le dédouanement attendu */
transitLeadDays: number;
/** Marge supplémentaire pour la demande de SCoC elle-même */
bufferDays: number;
/** Injecté pour rendre les tests déterministes */
now?: Date;
}
export function validateCertificateValidity(
product: ProductRecord,
options: ExpiryOptions,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
if (!product.pcocNumber) return issues; // Traité par la couverture réglementaire
const now = options.now ?? new Date();
const requiredValidUntil = addDays(
now,
options.transitLeadDays + options.bufferDays,
);
if (!product.pcocExpiry) {
issues.push({
sku: product.sku,
code: "PCOC_NO_EXPIRY",
severity: "blocker",
fixability: "documentation",
message:
`Le PCoC ${product.pcocNumber} est enregistré sans date d'expiration. ` +
"Sa validité ne peut être confirmée.",
});
return issues;
}
if (isBefore(product.pcocExpiry, now)) {
issues.push({
sku: product.sku,
code: "PCOC_EXPIRED",
severity: "blocker",
fixability: "certification",
message: `Le PCoC ${product.pcocNumber} a déjà expiré.`,
observed: product.pcocExpiry.toISOString().slice(0, 10),
});
return issues;
}
if (isBefore(product.pcocExpiry, requiredValidUntil)) {
const daysShort = differenceInDays(requiredValidUntil, product.pcocExpiry);
issues.push({
sku: product.sku,
code: "PCOC_EXPIRES_IN_TRANSIT",
severity: "blocker",
fixability: "certification",
message:
`Le PCoC ${product.pcocNumber} expire avant le dédouanement attendu — ` +
`${daysShort} jours manquants. À renouveler avant expédition.`,
observed: product.pcocExpiry.toISOString().slice(0, 10),
expected: `valide jusqu'au ${requiredValidUntil.toISOString().slice(0, 10)}`,
});
}
return issues;
}Cette seule règle — contrôler l'expiration par rapport à l'arrivée plutôt qu'à aujourd'hui — attrape un mode de défaillance que les processus centrés sur le portail ne peuvent structurellement pas voir, puisque le portail ne connaît jamais qu'un produit à un instant donné.
Étape 6 : Rapprocher les lignes d'expédition des produits enregistrés
C'est l'étape que personne d'autre ne construit, et c'est de là que proviennent réellement la plupart des rejets de certificat d'expédition.
Le PCoC décrit un produit. La facture commerciale décrit ce qui est dans le conteneur. Le SCoC ne sera émis que si les deux concordent. Or ils divergent en permanence : le marketing renomme un produit, un fournisseur expédie un numéro de modèle remplacé, la facture indique « LED Lamp 9W » alors que l'enregistrement dit « LED Bulb 9W ».
// src/rules/reconcile.ts
import type { ProductRecord, ValidationIssue } from "../types.js";
export interface ShipmentLine {
sku: string;
/** Description exactement telle qu'imprimée sur la facture commerciale */
invoiceDescription: string;
invoiceBrand: string;
invoiceModel: string;
hsCode: string;
quantity: number;
}
/** Casse pliée, espaces réduits, ponctuation retirée — comparer le sens, pas la forme. */
function canonical(value: string): string {
return value
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
export function reconcileShipment(
lines: ShipmentLine[],
catalogue: Map<string, ProductRecord>,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
for (const line of lines) {
const product = catalogue.get(line.sku);
if (!product) {
issues.push({
sku: line.sku,
code: "SHIP_SKU_UNREGISTERED",
severity: "blocker",
fixability: "certification",
message:
"La ligne d'expédition référence une SKU sans enregistrement produit. " +
"Elle ne peut être couverte par un PCoC existant.",
});
continue;
}
if (canonical(line.invoiceBrand) !== canonical(product.brand)) {
issues.push({
sku: line.sku,
code: "SHIP_BRAND_MISMATCH",
severity: "blocker",
fixability: "documentation",
message:
"La marque sur la facture ne correspond pas à la marque enregistrée. " +
"L'organisme de conformité rejettera le certificat d'expédition.",
observed: line.invoiceBrand,
expected: product.brand,
});
}
if (canonical(line.invoiceModel) !== canonical(product.model)) {
issues.push({
sku: line.sku,
code: "SHIP_MODEL_MISMATCH",
severity: "blocker",
fixability: "documentation",
message:
"Le modèle sur la facture ne correspond pas au modèle enregistré. " +
"C'est la cause la plus fréquente de rejet de SCoC.",
observed: line.invoiceModel,
expected: product.model,
});
}
const lineHs = line.hsCode.replace(/[.\s-]/g, "");
const productHs = product.hsCode.replace(/[.\s-]/g, "");
if (lineHs !== productHs) {
issues.push({
sku: line.sku,
code: "SHIP_HS_MISMATCH",
severity: "blocker",
fixability: "data-entry",
message:
"Le code SH de la facture diffère de celui du produit enregistré. " +
"La douane et Saber résoudront des réglementations différentes.",
observed: lineHs,
expected: productHs,
});
}
if (line.quantity <= 0) {
issues.push({
sku: line.sku,
code: "SHIP_QUANTITY_INVALID",
severity: "blocker",
fixability: "data-entry",
message: "La quantité de la ligne d'expédition doit être supérieure à zéro.",
observed: String(line.quantity),
});
}
}
return issues;
}Notez que l'utilitaire canonical() s'appuie sur les propriétés Unicode (\p{L}, \p{N}) plutôt que sur [a-z0-9]. Les catalogues de ce marché portent des noms de produits en arabe, et une classe de caractères limitée à l'ASCII les réduirait à néant, signalant chaque produit à nom arabe comme une divergence.
Étape 7 : Composer le pipeline de validation
Les règles étant écrites comme des fonctions pures indépendantes, la composition devient triviale — et c'est là le bénéfice de cette conception. Chaque règle prend un enregistrement et renvoie des anomalies ; rien ne partage d'état mutable.
// src/pipeline.ts
import { validateHsCode } from "./rules/hs-code.js";
import { validateRegulationCoverage } from "./rules/regulation.js";
import { validateCertificateValidity, type ExpiryOptions } from "./rules/certificate.js";
import { reconcileShipment, type ShipmentLine } from "./rules/reconcile.js";
import { ProductRecordSchema, type ProductRecord, type ValidationIssue } from "./types.js";
export interface ValidationInput {
rawProducts: unknown[];
shipmentLines?: ShipmentLine[];
expiry: ExpiryOptions;
}
export interface ValidationResult {
issues: ValidationIssue[];
validProducts: ProductRecord[];
parseFailures: number;
}
export function validateCatalogue(input: ValidationInput): ValidationResult {
const issues: ValidationIssue[] = [];
const validProducts: ProductRecord[] = [];
let parseFailures = 0;
for (const raw of input.rawProducts) {
const parsed = ProductRecordSchema.safeParse(raw);
if (!parsed.success) {
parseFailures++;
// Tenter de récupérer une SKU pour le rapport même sur une ligne
// malformée, sinon l'opérateur ne peut pas retrouver la ligne fautive.
const sku =
typeof raw === "object" && raw !== null && "sku" in raw
? String((raw as Record<string, unknown>).sku)
: "unknown";
for (const issue of parsed.error.issues) {
issues.push({
sku,
code: "SCHEMA_INVALID",
severity: "blocker",
fixability: "data-entry",
message: `Champ "${issue.path.join(".")}" : ${issue.message}`,
});
}
continue;
}
const product = parsed.data;
validProducts.push(product);
issues.push(...validateHsCode(product));
issues.push(...validateRegulationCoverage(product));
issues.push(...validateCertificateValidity(product, input.expiry));
}
if (input.shipmentLines?.length) {
const catalogue = new Map(validProducts.map((p) => [p.sku, p]));
issues.push(...reconcileShipment(input.shipmentLines, catalogue));
}
return { issues, validProducts, parseFailures };
}Étape 8 : Un rapport exploitable par l'équipe conformité
Une liste plate de six cents anomalies n'est pas un livrable. Groupez par facilité de correction, car cela correspond à qui fait le travail et en combien de temps.
// src/report/summarise.ts
import type { Fixability, ValidationIssue } from "../types.js";
export interface ReportSection {
fixability: Fixability;
headline: string;
leadTime: string;
blockers: number;
affectedSkus: string[];
issues: ValidationIssue[];
}
const SECTION_META: Record<Fixability, { headline: string; leadTime: string }> = {
"data-entry": {
headline: "À corriger dans votre propre système",
leadTime: "minutes à heures",
},
documentation: {
headline: "Demander des documents corrigés au fournisseur",
leadTime: "jours",
},
certification: {
headline: "Nécessite une évaluation de conformité — commencez maintenant",
leadTime: "semaines ; c'est votre chemin critique",
},
};
const ORDER: Fixability[] = ["certification", "documentation", "data-entry"];
export function buildReport(issues: ValidationIssue[]): ReportSection[] {
return ORDER.map((fixability) => {
const scoped = issues.filter((i) => i.fixability === fixability);
const meta = SECTION_META[fixability];
return {
fixability,
headline: meta.headline,
leadTime: meta.leadTime,
blockers: scoped.filter((i) => i.severity === "blocker").length,
affectedSkus: [...new Set(scoped.map((i) => i.sku))],
issues: scoped,
};
}).filter((section) => section.issues.length > 0);
}Les anomalies de type certification sont triées en premier, délibérément. Ce sont elles qui portent des délais de plusieurs semaines : elles doivent être visibles dès le premier jour — même si une faute de frappe est techniquement « plus corrigeable », ce n'est pas la faute de frappe qui immobilise un conteneur dans un port.
Étape 9 : Assembler en outil CLI
// src/cli.ts
import { readFileSync } from "node:fs";
import { parse } from "csv-parse/sync";
import { validateCatalogue } from "./pipeline.js";
import { buildReport } from "./report/summarise.js";
const [, , cataloguePath, transitDaysArg] = process.argv;
if (!cataloguePath) {
console.error("Usage: npm run validate -- <catalogue.csv> [transitDays]");
process.exit(1);
}
const rows = parse(readFileSync(cataloguePath, "utf8"), {
columns: true,
skip_empty_lines: true,
trim: true,
});
const result = validateCatalogue({
rawProducts: rows,
expiry: {
transitLeadDays: Number(transitDaysArg ?? 28),
bufferDays: 14,
},
});
const report = buildReport(result.issues);
console.log(`\nValidated ${rows.length} rows`);
console.log(`Parse failures: ${result.parseFailures}`);
console.log(`Total issues: ${result.issues.length}\n`);
for (const section of report) {
console.log(`── ${section.headline} (${section.leadTime})`);
console.log(
` ${section.blockers} blockers across ${section.affectedSkus.length} SKUs\n`,
);
for (const issue of section.issues.slice(0, 20)) {
console.log(` [${issue.code}] ${issue.sku}`);
console.log(` ${issue.message}`);
if (issue.observed) console.log(` observed: ${issue.observed}`);
if (issue.expected) console.log(` expected: ${issue.expected}`);
console.log();
}
if (section.issues.length > 20) {
console.log(` ... and ${section.issues.length - 20} more\n`);
}
}
// Sortie non nulle pour bloquer un job CI ou un pipeline avant expédition
const hasBlockers = result.issues.some((i) => i.severity === "blocker");
process.exit(hasBlockers ? 1 : 0);Exécutez :
npm run validate -- ./data/catalogue.csv 35Tester votre implémentation
Les règles sont des fonctions pures, ce qui rend leur test agréable. Injectez now pour que les tests d'expiration ne pourrissent pas.
// tests/certificate.test.ts
import { describe, expect, it } from "vitest";
import { validateCertificateValidity } from "../src/rules/certificate.js";
import type { ProductRecord } from "../src/types.js";
const base: ProductRecord = {
sku: "LED-9W-E27",
nameEn: "LED Bulb 9W E27",
brand: "Lumina",
model: "LX-9W-E27",
hsCode: "851660100000",
countryOfOrigin: "CN",
manufacturer: "Lumina Lighting Co Ltd",
pcocNumber: "PC-2026-004411",
pcocExpiry: new Date("2026-09-01"),
};
describe("certificate validity", () => {
const now = new Date("2026-08-09");
it("flags a certificate that expires while goods are in transit", () => {
const issues = validateCertificateValidity(base, {
transitLeadDays: 28,
bufferDays: 14,
now,
});
expect(issues).toHaveLength(1);
expect(issues[0]?.code).toBe("PCOC_EXPIRES_IN_TRANSIT");
expect(issues[0]?.fixability).toBe("certification");
});
it("passes a certificate valid beyond arrival plus buffer", () => {
const issues = validateCertificateValidity(
{ ...base, pcocExpiry: new Date("2027-01-01") },
{ transitLeadDays: 28, bufferDays: 14, now },
);
expect(issues).toHaveLength(0);
});
});Et la logique de rapprochement, qui doit prouver qu'elle gère correctement le texte arabe :
// tests/reconcile.test.ts
import { describe, expect, it } from "vitest";
import { reconcileShipment } from "../src/rules/reconcile.js";
import type { ProductRecord } from "../src/types.js";
const product: ProductRecord = {
sku: "LED-9W-E27",
nameEn: "LED Bulb 9W E27",
nameAr: "مصباح ليد ٩ واط",
brand: "Lumina",
model: "LX-9W-E27",
hsCode: "851660100000",
countryOfOrigin: "CN",
manufacturer: "Lumina Lighting Co Ltd",
};
describe("shipment reconciliation", () => {
const catalogue = new Map([[product.sku, product]]);
it("treats punctuation and case differences as equivalent", () => {
const issues = reconcileShipment(
[
{
sku: "LED-9W-E27",
invoiceDescription: "LED Bulb 9W",
invoiceBrand: "LUMINA",
invoiceModel: "LX 9W E27",
hsCode: "8516.60.10.0000",
quantity: 500,
},
],
catalogue,
);
expect(issues).toHaveLength(0);
});
it("catches a superseded model number", () => {
const issues = reconcileShipment(
[
{
sku: "LED-9W-E27",
invoiceDescription: "LED Bulb 9W",
invoiceBrand: "Lumina",
invoiceModel: "LX-9W-E27-V2",
hsCode: "851660100000",
quantity: 500,
},
],
catalogue,
);
expect(issues.map((i) => i.code)).toContain("SHIP_MODEL_MISMATCH");
});
});npm testDépannage
Tous les codes SH échouent en HS_TOO_SHORT. Votre export est presque certainement passé par Excel, qui traite les codes tarifaires comme des nombres et supprime zéros initiaux et précision finale. Exportez en texte, ou lisez le fichier en forçant toutes les colonnes en chaîne avant l'analyse.
Les noms de produits arabes sont signalés comme divergents. Vérifiez que votre normalisation utilise des classes de caractères Unicode (\p{L}) et non [a-z]. Assurez-vous aussi que le fichier est lu en UTF-8 — un fichier mal décodé produit du mojibake qui ne correspondra jamais.
REG_UNMATCHED se déclenche sur la majorité du catalogue. Votre table de référence est trop pauvre. Celle de l'étape 4 compte trois entrées ; une table de production en compte des milliers. Alimentez-la depuis la liste de codes SH publiée par la SASO avant de tirer des conclusions.
Un produit passe la validation et est quand même rejeté. C'est attendu, et il faut le dire honnêtement : ce validateur prédit les rejets mécaniques — codes malformés, certificats manquants, dérive entre facture et enregistrement. Il ne peut pas prédire le jugement technique d'un organisme de conformité sur un rapport d'essai. Voyez-le comme un moyen d'éliminer les échecs évitables, pas comme une garantie.
Les contrôles de certificat passent en local mais échouent en fin de trimestre. Vérifiez que vous injectez un vrai now en production, et non une date de test oubliée.
Pour aller plus loin
Une fois le validateur en place sur votre catalogue, les extensions naturelles sont :
- Versionner la table de référence. Conservez la version du jeu de règles ayant produit chaque rapport, pour expliquer pourquoi un produit valide en mars échoue en août.
- L'exécuter en CI. Le code de sortie non nul de l'étape 9 permet à un pipeline avant expédition de bloquer sur les bloquants.
- Renvoyer les résultats dans l'ERP. Un statut de conformité par référence est bien plus utile dans le système où se font les achats que dans un terminal.
- Ajouter un calendrier de renouvellement. Triez par
pcocExpiryet vous obtenez une feuille de route de certification au lieu d'une urgence récurrente.
À lire également sur ce site :
- Rejets Saber : pourquoi vos cargaisons bloquent au port — le contexte métier de ce tutoriel
- Créer un générateur et validateur de fichiers WPS en TypeScript — le même schéma de validation avant soumission appliqué à la paie saoudienne
- Intégration Qiwa pour les SIRH et la conformité Nitaqat — une autre plateforme saoudienne sans API en masse
- La facturation électronique ZATCA en Arabie Saoudite — d'où viennent les codes tarifaires à 12 chiffres
Conclusion
L'absence d'API de soumission Saber ressemble à une limitation, jusqu'à ce qu'on remarque ce qu'elle implique réellement. Si la plateforme ne vous dira pas en masse si vos données sont acceptables, alors le seul levier disponible se situe en amont, dans le catalogue que vous contrôlez déjà.
Ce recadrage vaut plus que le code. La plupart des importateurs traitent les rejets comme un problème douanier et achètent leur sortie de crise, dossier par dossier, auprès d'un transitaire. Or les rejets sont un problème de qualité de données, ils sont visibles des semaines avant qu'un conteneur n'appareille, et ce sont les mêmes quelques modes de défaillance qui se répètent : codes à la mauvaise précision, certificats expirant en pleine traversée, factures ayant cessé de correspondre aux enregistrements le jour où quelqu'un a renommé un produit.
Chacun d'eux est vérifiable en quelques centaines de lignes de TypeScript, sur des données que vous possédez déjà.
Si vous pilotez une opération d'import vers l'Arabie Saoudite sur un ERP qui n'a jamais été conçu pour la conformité SASO, et que vous voulez une couche de validation branchée sur les systèmes que vous exploitez déjà plutôt qu'un script maintenu sur le portable de quelqu'un — dites-nous à quoi ressemble votre catalogue. Nous vous dirons honnêtement lesquels de vos rejets sont évitables et lesquels ne le sont pas.