كل دليل عن سابر يصف الرحلة نفسها: سجّل الدخول إلى المنصة، أنشئ حساب المنشأة، اختر جهة تقويم المطابقة، أدخل بيانات المنتج، ادفع، استلم الشهادة. جميعها مكتوبة لشخص ينقر داخل بوابة إلكترونية، منتجًا واحدًا في كل مرة.
ولا واحد منها مكتوب لمن يملك كتالوجًا فيه أربعة آلاف صنف، ويصدّر إلى السعودية كل شهر، ويشاهد نسبة ثابتة من طلبات شهادات الإرسالية تعود مرفوضة — دون أن يعرف أيًّا من الأربعة آلاف سيفشل، إلى أن تصبح الحاوية في ميناء جدة الإسلامي وعدّاد أرضيات التخزين يعمل.
هذه الفجوة موجودة بسبب حقيقة بنيوية تناولناها في لماذا تتعطل شحناتك في الميناء: لا توجد واجهة برمجية للتقديم الجماعي على سابر. لا يمكنك دفع أربعة آلاف منتج برمجيًا إلى المنصة واستلام أربعة آلاف نتيجة. سابر منصة تُشغَّل بشريًا بحكم تصميمها.
هذا القيد ليس طريقًا مسدودًا. إنه المواصفة الكاملة لما ينبغي أن تبنيه بدلًا من ذلك. إذا كنت لا تستطيع سؤال المنصة عمّا إذا كانت بياناتك ستُقبل، فإنك تبني ما يجيب عن هذا السؤال محليًا — قبل التقديم، على كتالوجك أنت، وبالجملة.
هذا الدرس يبني ذلك الشيء.
المتطلبات المسبقة
قبل البدء، تأكد من توفر:
- Node.js 20+ وnpm
- أساسيات TypeScript — الواجهات، الأنواع العامة، الاتحادات المميزة
- معرفة عملية بـ Zod أو مُدقِّق مخططات مشابه (سنستخدم Zod v4)
- الوصول إلى كتالوج منتجاتك بصيغة منظمة (CSV، تصدير من قاعدة بيانات، مستخرج من نظام ERP)
- إلمام بمعنى شهادة مطابقة المنتج (PCoC) وشهادة مطابقة الإرسالية (SCoC) — المقال أعلاه يغطي ذلك إن احتجت
لا تحتاج إلى بيانات دخول منصة سابر لمتابعة هذا الدرس. وهذا هو بيت القصيد: كل ما هنا يعمل على بياناتك أنت، إضافة إلى البيانات المرجعية المنشورة من الهيئة السعودية للمواصفات.
ما الذي ستبنيه
أداة سطر أوامر تأخذ كتالوج منتجات وتُخرج تقرير رفض مُصنَّفًا حسب الأولوية. عمليًا ستقوم بـ:
- توحيد الصفوف الفوضوية إلى سجل منتج قياسي
- التحقق من الرموز الجمركية وفق بنية التعرفة السعودية ذات الاثني عشر خانة — لا الرمز الدولي ذي الست خانات
- استنتاج اللوائح الفنية لكل رمز جمركي من جدول مرجعي محلي، لتعرف أي المنتجات يحتاج تقويم مطابقة أصلًا
- فحص تغطية الشهادات وصلاحيتها بمهلة قابلة للضبط، بحيث تُرصد الشهادة التي تنتهي أثناء الشحن قبل الإرسال
- مطابقة بنود الإرسالية مع المنتجات المسجلة، وهنا ينشأ معظم رفض شهادات الإرسالية فعليًا
- إصدار تقرير مُرتَّب مجمَّع حسب قابلية الإصلاح، لا حسب رقم الصف
الناتج قائمة يعالجها فريق الامتثال في بعد ظهر واحد، بدل رفضٍ يكتشفه بعد ستة أسابيع في ميناء.
هذه البنية التي نتجه إليها:
catalogue.csv ──► normalise ──► ProductRecord[]
│
┌────────────────┼────────────────┐
▼ ▼ ▼
hs-code rules regulation map certificate ledger
│ │ │
└────────────────┼────────────────┘
▼
ValidationIssue[]
▼
triaged rejection report
الخطوة ١: تهيئة المشروع
أنشئ المشروع وثبّت الاعتماديات.
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 --initاضبط tsconfig.json لهدف Node حديث:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}أضف "type": "module" إلى package.json مع بعض الأوامر:
{
"type": "module",
"scripts": {
"validate": "tsx src/cli.ts",
"test": "vitest run"
}
}أنشئ هيكل المجلدات:
mkdir -p src/{rules,data,report} testsملاحظة حول
noUncheckedIndexedAccess. تفعيلها مقصود. هذا المشروع يجري بحثًا كثيرًا بالمفتاح في جداول مرجعية، والفرق بين «هذا الرمز الجمركي لا لائحة له» و«هذا الرمز أعطى قيمة غير معرّفة بسبب خطأ إملائي» هو تحديدًا صنف الأخطاء الذي يُشحن كتالوجًا فاسدًا. دع المُصرِّف يُجبرك على معالجة الحالة المفقودة.
الخطوة ٢: نمذجة سجل المنتج القياسي
كل ما يأتي لاحقًا يعتمد على شكل واحد محدد جيدًا. الكتالوجات الحقيقية تصل كجداول بأسماء أعمدة غير متسقة، وخليط من العربية والإنجليزية، ومسافات زائدة، ورموز جمركية مخزّنة كأرقام أكل Excel صفرها البادئ.
عرّف السجل القياسي أولًا، ثم اكتب المحوّلات إليه.
// src/types.ts
import { z } from "zod";
export const ProductRecordSchema = z.object({
/** رقم الصنف الداخلي — مفتاح الربط لكل شيء */
sku: z.string().min(1),
/** اسم المنتج كما سيظهر على الشهادة والفاتورة */
nameEn: z.string().min(1),
nameAr: z.string().optional(),
/** العلامة التجارية والموديل يجب أن يطابقا البضاعة الفعلية والفاتورة */
brand: z.string().min(1),
model: z.string().min(1),
/** الرمز الجمركي السعودي بـ 12 خانة، كنص للحفاظ على الأصفار البادئة */
hsCode: z.string(),
/** بلد الصنع، ISO 3166-1 alpha-2 */
countryOfOrigin: z.string().length(2),
/** الاسم النظامي للمصنّع، كما هو مطبوع على تقرير الاختبار */
manufacturer: z.string().min(1),
/** رقم شهادة مطابقة المنتج، إن صدرت */
pcocNumber: z.string().optional(),
pcocExpiry: z.coerce.date().optional(),
});
export type ProductRecord = z.infer<typeof ProductRecordSchema>;الآن نوع المشكلة. هذا أهم قرار تصميمي في المشروع، ويستحق التوقف عنده.
// src/types.ts (تكملة)
export type Severity = "blocker" | "warning" | "info";
/**
* قابلية الإصلاح تحدد تجميع التقرير. مسؤول الامتثال لا يريد المشكلات
* مرتبة حسب رقم الصف — يريد أن يعرف ما يمكنه إصلاحه اليوم مقابل ما
* يتطلب تقرير اختبار جديدًا ومهلة ستة أسابيع.
*/
export type Fixability =
| "data-entry" // إصلاح في نظامك أنت، دقائق
| "documentation" // طلب مستند من المورّد، أيام
| "certification"; // تقويم مطابقة جديد، أسابيع
export interface ValidationIssue {
sku: string;
code: string;
severity: Severity;
fixability: Fixability;
message: string;
/** القيمة الملاحَظة، ليكون التقرير قابلًا للتنفيذ دون فتح الملف المصدر */
observed?: string;
/** الشكل الصحيح المتوقع */
expected?: string;
}التجميع حسب قابلية الإصلاح بدل الخطورة هو ما يجعل هذه الأداة تُستخدَم فعلًا بدل أن تُولَّد مرة وتُهمَل. مشكلتان حاجبتان ليستا متكافئتين إن كانت إحداهما خطأً مطبعيًا والأخرى تقرير اختبار مفقودًا.
الخطوة ٣: التحقق من الرمز الجمركي ذي الاثنتي عشرة خانة
هذه القاعدة التي تخطئ فيها معظم الكتالوجات. رمز النظام المنسق الدولي ست خانات. السعودية — والتعرفة الخليجية الموحدة — تمدّه إلى اثنتي عشرة. سابر يستنتج اللوائح الفنية على مستوى الاثنتي عشرة خانة كاملة، لذا فالكتالوج الذي يحمل رموزًا من ست أو ثماني خانات ليس ناقص الدقة فحسب، بل غير قابل للاستنتاج أصلًا.
تتفكك البنية هكذا:
| الخانات | المعنى |
|---|---|
| ١–٢ | الفصل |
| ٣–٤ | البند الرئيسي |
| ٥–٦ | البند الفرعي (نهاية الرمز الدولي) |
| ٧–٨ | التقسيم الفرعي للتعرفة الخليجية الموحدة |
| ٩–١٢ | التقسيم الإحصائي الوطني |
اكتب المُدقِّق:
// 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 هو العدو هنا. تصل الرموز كـ "8516.60.00" أو "851660"
// أو كعدد عشري فقد صفره البادئ.
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: "الرمز الجمركي يحتوي على محارف غير رقمية بعد التوحيد.",
observed: raw,
expected: "12 خانة، مثال: 851660100000",
});
return issues; // لا جدوى من فحص الطول على قيمة تالفة
}
if (normalised.length === 12) {
return issues; // صحيح
}
if (normalised.length < 12) {
// الحالة الشائعة: رمز دولي من 6 أو 8 خانات استُورد
// ولم يمدّه أحد إلى المستوى الوطني السعودي.
issues.push({
sku: product.sku,
code: "HS_TOO_SHORT",
severity: "blocker",
fixability: "data-entry",
message:
`الرمز الجمركي فيه ${normalised.length} خانة. سابر يستنتج اللوائح ` +
"الفنية على 12 خانة؛ الرمز الأقصر لا يمكن ربطه بلائحة.",
observed: normalised,
expected: `${normalised.padEnd(12, "0")} (تحقّق — لا تُكمل بالأصفار عشوائيًا)`,
});
} else {
issues.push({
sku: product.sku,
code: "HS_TOO_LONG",
severity: "blocker",
fixability: "data-entry",
message: `الرمز الجمركي فيه ${normalised.length} خانة، والمتوقع 12.`,
observed: normalised,
});
}
return issues;
}لا تُكمل بالأصفار تلقائيًا. حقل
expectedأعلاه يقترح قيمة مُكمَّلة كتلميح لإنسان، والرسالة تقول ذلك صراحة. خانات التقسيم الوطني تحمل معنى — وإكمال رمز من ثماني خانات إلى اثنتي عشرة بالأصفار قد يشير بصمت إلى فئة منتجات مختلفة بمتطلبات تنظيمية مختلفة. مهمة المُدقِّق إظهار الفجوة، لا التخمين للقفز فوقها.
الخطوة ٤: استنتاج اللوائح الفنية
كون الرمز الجمركي سليم البنية لا يقول شيئًا عن حاجة المنتج إلى شهادة. ذلك يعتمد على اللائحة الفنية السعودية التي تغطي الرمز.
الهيئة السعودية للمواصفات تنشر هذا الربط. قائمة رموز النظام المنسق القابلة للبحث متاحة على saber.sa/home/hscodes، كما تتيح الهيئة واجهات البيانات المفتوحة لمجموعات بياناتها المنشورة. ابنِ جدولًا مرجعيًا محليًا من هذه المصادر، يُحدَّث وفق جدولة دورية — لا وقت التحقق.
// src/data/regulations.ts
export interface RegulationEntry {
/** رمز من 12 خانة، أو بادئة للمطابقة على نطاق */
hsPrefix: string;
regulationCode: string;
regulationNameEn: string;
/** هل تتطلب هذه اللائحة شهادة منتج قبل إصدار شهادة الإرسالية؟ */
requiresPcoc: boolean;
/** الفئات عالية الخطورة تخضع لتدقيق إضافي ومهل أطول */
riskLevel: "low" | "medium" | "high";
}
/**
* مجموعة توضيحية مصغّرة. املأ الجدول الحقيقي من بيانات الهيئة المنشورة
* وأصدر له نسخًا — اللوائح تتغير، وتحتاج أن تعرف أي مجموعة قواعد
* جرى التحقق السابق وفقها.
*/
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",
},
];المطابقة بأطول بادئة هي استراتيجية البحث الصحيحة، لأن اللوائح تُعرَّف على مستويات تحديد متفاوتة — قد تغطي القاعدة فصلًا كاملًا، أو بندًا واحدًا من اثنتي عشرة خانة.
// 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, "");
// أطول بادئة تفوز: قاعدة محددة بـ 12 خانة تسبق قاعدة فصل بـ 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) {
// المنتجات غير المنظّمة موجودة فعلًا. لكن الرمز غير المطابق يكون
// رمزًا خاطئًا أكثر بكثير من كونه منتجًا معفى — لذا حذّر، ولا تُمرّر.
issues.push({
sku: product.sku,
code: "REG_UNMATCHED",
severity: "warning",
fixability: "data-entry",
message:
"لم تُطابق أي لائحة فنية هذا الرمز الجمركي. تأكد أن المنتج معفى " +
"فعلًا وليس مصنّفًا تصنيفًا خاطئًا قبل الشحن.",
observed: product.hsCode,
});
return issues;
}
if (regulation.requiresPcoc && !product.pcocNumber) {
issues.push({
sku: product.sku,
code: "PCOC_MISSING",
severity: "blocker",
// هذه هي المكلفة: تحتاج جهة تقويم مطابقة وتقارير اختبار
// وأسابيع من الوقت التقويمي.
fixability: "certification",
message:
`${regulation.regulationNameEn} (${regulation.regulationCode}) تتطلب ` +
"شهادة مطابقة منتج. لا توجد شهادة مسجلة لهذا الصنف.",
});
}
if (regulation.riskLevel === "high") {
issues.push({
sku: product.sku,
code: "REG_HIGH_RISK",
severity: "info",
fixability: "documentation",
message:
`مشمول بلائحة عالية الخطورة (${regulation.regulationCode}). ` +
"توقّع تدقيقًا إضافيًا ومهل تقويم أطول.",
});
}
return issues;
}الخطوة ٥: انتهاء صلاحية الشهادة مع مهلة الشحن
شهادة مطابقة منتج صالحة اليوم وتنتهي بعد ثمانية عشر يومًا هي مشكلة إذا كان الشحن البحري يستغرق ثمانية وعشرين. الفحص الساذج — هل تاريخ الانتهاء بعد اليوم — يُمرّرها، ثم يفشل طلب شهادة الإرسالية بعد أن تكون البضاعة قد أبحرت.
تحقّق مقابل التاريخ الذي يجب أن تظل الشهادة صالحة فيه، لا مقابل اليوم.
// src/rules/certificate.ts
import { addDays, differenceInDays, isBefore } from "date-fns";
import type { ProductRecord, ValidationIssue } from "../types.js";
export interface ExpiryOptions {
/** الأيام من وقت التحقق حتى التخليص الجمركي المتوقع */
transitLeadDays: number;
/** هامش إضافي لطلب شهادة الإرسالية نفسه */
bufferDays: number;
/** مُحقَن ليكون الاختبار حتميًا */
now?: Date;
}
export function validateCertificateValidity(
product: ProductRecord,
options: ExpiryOptions,
): ValidationIssue[] {
const issues: ValidationIssue[] = [];
if (!product.pcocNumber) return issues; // تعالجها قاعدة تغطية اللوائح
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:
`الشهادة ${product.pcocNumber} مسجلة بلا تاريخ انتهاء. ` +
"لا يمكن تأكيد صلاحيتها.",
});
return issues;
}
if (isBefore(product.pcocExpiry, now)) {
issues.push({
sku: product.sku,
code: "PCOC_EXPIRED",
severity: "blocker",
fixability: "certification",
message: `الشهادة ${product.pcocNumber} منتهية الصلاحية بالفعل.`,
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:
`الشهادة ${product.pcocNumber} تنتهي قبل التخليص الجمركي المتوقع ` +
`بـ ${daysShort} يومًا. جدّدها قبل الشحن.`,
observed: product.pcocExpiry.toISOString().slice(0, 10),
expected: `صالحة حتى ${requiredValidUntil.toISOString().slice(0, 10)}`,
});
}
return issues;
}هذه القاعدة وحدها — فحص الانتهاء مقابل الوصول لا مقابل اليوم — تلتقط نمط فشل لا تستطيع سير العمل المعتمدة على البوابة رؤيته بنيويًا، لأن البوابة لا تعرف إلا منتجًا واحدًا في لحظة واحدة.
الخطوة ٦: مطابقة بنود الإرسالية مع المنتجات المسجلة
هذه الخطوة التي لا يبنيها أحد غيرك، ومنها يأتي معظم رفض شهادات الإرسالية فعليًا.
شهادة المنتج تصف منتجًا. الفاتورة التجارية تصف ما في الحاوية. ولن تصدر شهادة الإرسالية إلا إذا اتفق الاثنان. وهما ينحرفان باستمرار: التسويق يعيد تسمية منتج، والمورّد يشحن رقم موديل مُحدَّثًا، والفاتورة تقول «LED Lamp 9W» بينما التسجيل يقول «LED Bulb 9W».
// src/rules/reconcile.ts
import type { ProductRecord, ValidationIssue } from "../types.js";
export interface ShipmentLine {
sku: string;
/** الوصف كما هو مطبوع تمامًا على الفاتورة التجارية */
invoiceDescription: string;
invoiceBrand: string;
invoiceModel: string;
hsCode: string;
quantity: number;
}
/** توحيد حالة الأحرف والمسافات وإسقاط الترقيم — قارن المعنى لا التنسيق. */
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:
"بند الإرسالية يشير إلى صنف بلا سجل منتج مسجل. " +
"لا يمكن أن تغطيه شهادة مطابقة منتج قائمة.",
});
continue;
}
if (canonical(line.invoiceBrand) !== canonical(product.brand)) {
issues.push({
sku: line.sku,
code: "SHIP_BRAND_MISMATCH",
severity: "blocker",
fixability: "documentation",
message:
"العلامة التجارية في الفاتورة لا تطابق العلامة في المنتج المسجل. " +
"ستَرفض جهة تقويم المطابقة شهادة الإرسالية.",
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:
"الموديل في الفاتورة لا يطابق الموديل المسجل. هذا هو السبب " +
"الأكثر شيوعًا لرفض شهادة الإرسالية.",
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:
"الرمز الجمركي في الفاتورة يختلف عن رمز المنتج المسجل. " +
"ستستنتج الجمارك وسابر لوائح مختلفة للبضاعة نفسها.",
observed: lineHs,
expected: productHs,
});
}
if (line.quantity <= 0) {
issues.push({
sku: line.sku,
code: "SHIP_QUANTITY_INVALID",
severity: "blocker",
fixability: "data-entry",
message: "كمية بند الإرسالية يجب أن تكون أكبر من صفر.",
observed: String(line.quantity),
});
}
}
return issues;
}لاحظ أن الدالة المساعدة canonical() تستخدم خصائص يونيكود (\p{L} و\p{N}) بدل [a-z0-9]. الكتالوجات في هذا السوق تحمل أسماء منتجات بالعربية، وفئة محارف تقتصر على ASCII ستمحوها بالكامل وتُبلّغ عن كل منتج عربي الاسم كعدم تطابق.
الخطوة ٧: تركيب خط التحقق
مع كتابة القواعد كدوال نقية مستقلة، يصبح التركيب تافهًا — وهذا هو عائد التصميم. كل قاعدة تأخذ سجلًا وتُعيد مشكلات؛ ولا شيء يتشارك حالة قابلة للتغيير.
// 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++;
// حاول استخراج رقم الصنف للتقرير حتى من صف مشوّه،
// وإلا لن يستطيع المشغّل إيجاد السطر المخالف.
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: `الحقل "${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 };
}الخطوة ٨: تقرير يستطيع فريق الامتثال التصرف بناءً عليه
قائمة مسطحة من ستمائة مشكلة ليست مُخرَجًا. جمّع حسب قابلية الإصلاح، لأنها تُقابل من ينفّذ العمل وكم يستغرق.
// 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: "أصلحها في نظامك أنت",
leadTime: "دقائق إلى ساعات",
},
documentation: {
headline: "اطلب مستندات مصححة من المورّد",
leadTime: "أيام",
},
certification: {
headline: "تتطلب تقويم مطابقة — ابدأ الآن",
leadTime: "أسابيع؛ هذا مسارك الحرج",
},
};
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);
}مشكلات فئة الشهادات تُرتَّب أولًا عن قصد. هي صاحبة المهل الممتدة أسابيع، فيجب أن تكون ظاهرة من اليوم الأول — حتى وإن كان الخطأ المطبعي «أسهل إصلاحًا» تقنيًا، فالخطأ المطبعي ليس ما يُجلس حاوية في ميناء.
الخطوة ٩: الربط في أداة سطر أوامر
// 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`);
}
}
// خروج بقيمة غير صفرية ليتمكن هذا من حجب مهمة CI أو خط ما قبل الشحن
const hasBlockers = result.issues.some((i) => i.severity === "blocker");
process.exit(hasBlockers ? 1 : 0);شغّلها:
npm run validate -- ./data/catalogue.csv 35اختبار التنفيذ
القواعد دوال نقية، ما يجعل اختبارها مريحًا. احقن now حتى لا تتعفن اختبارات الصلاحية.
// 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);
});
});ومنطق المطابقة، الذي يحتاج أن يُثبت تعامله الصحيح مع النص العربي:
// 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 testاستكشاف الأخطاء
كل الرموز الجمركية تفشل بـ HS_TOO_SHORT. غالبًا مرّ ملف التصدير عبر Excel، الذي يعامل الرموز الجمركية كأرقام فيحذف الأصفار البادئة والدقة اللاحقة. صدّر الملف كنص، أو اقرأه بإجبار كل الأعمدة على نوع نصي قبل التحليل.
أسماء المنتجات العربية تظهر كعدم تطابق. تأكد أن التوحيد يستخدم فئات محارف مدركة ليونيكود (\p{L}) لا [a-z]. وتأكد أن الملف يُقرأ بترميز UTF-8 — الملف المفكوك بترميز خاطئ يُنتج رموزًا مشوّهة لن تتطابق أبدًا.
REG_UNMATCHED يظهر على معظم الكتالوج. جدولك المرجعي أفقر مما يجب. الجدول التوضيحي في الخطوة ٤ فيه ثلاثة مدخلات؛ الجدول الإنتاجي فيه آلاف. املأه من قائمة رموز النظام المنسق المنشورة قبل استخلاص أي نتائج من المخرجات.
منتج يجتاز التحقق ويُرفض رغم ذلك. متوقع، ومن الأمانة قوله: هذا المُدقِّق يتنبأ بحالات الرفض الآلية — الرموز المشوّهة، الشهادات المفقودة، انحراف الفاتورة عن التسجيل. لا يستطيع التنبؤ بحكم جهة تقويم المطابقة الفني على تقرير اختبار. اعتبره وسيلة لإزالة حالات الفشل القابلة للتجنب، لا ضمانًا.
فحوص الشهادات تنجح محليًا وتفشل قرب نهاية الربع. تأكد أنك تحقن now حقيقيًا في الإنتاج بدل تاريخ ثابت بقي من الاختبارات.
الخطوات التالية
بعد أن يعمل المُدقِّق الأساسي على كتالوجك، الامتدادات الطبيعية هي:
- إصدار نسخ للجدول المرجعي. خزّن أي نسخة قواعد أنتجت كل تقرير، لتستطيع تفسير سبب فشل منتج في أغسطس بعد نجاحه في مارس.
- شغّله في CI. رمز الخروج غير الصفري في الخطوة ٩ يعني أن خط ما قبل الشحن يستطيع التوقف عند المشكلات الحاجبة.
- أعد النتائج إلى نظام ERP. حالة امتثال لكل صنف أنفع في النظام الذي يجري فيه الشراء منها في نافذة طرفية.
- أضف تقويم تجديد. رتّب حسب
pcocExpiryوستحصل على خارطة طريق للشهادات بدل طوارئ متكررة.
قراءات ذات صلة على هذا الموقع:
- رفض شهادة سابر: لماذا تتعطل شحناتك في الميناء — الحالة العملية وراء هذا الدرس
- بناء مولّد ومُدقِّق ملفات حماية الأجور بـ TypeScript — نمط التحقق قبل التقديم نفسه مطبقًا على الرواتب السعودية
- تكامل قوى مع أنظمة الموارد البشرية وامتثال نطاقات — منصة سعودية أخرى بلا واجهة برمجية جماعية
- الفوترة الإلكترونية في السعودية مع هيئة الزكاة والضريبة — من حيث تأتي الرموز الجمركية ذات الاثنتي عشرة خانة
الخلاصة
غياب واجهة برمجية للتقديم على سابر يبدو قيدًا، إلى أن تنتبه إلى ما يعنيه فعلًا. إذا كانت المنصة لن تخبرك بالجملة عمّا إذا كانت بياناتك مقبولة، فإن الرافعة الوحيدة المتاحة تقع أعلى المجرى، في الكتالوج الذي تتحكم فيه أصلًا.
إعادة التأطير هذه أثمن من الشيفرة. معظم المستوردين يعاملون الرفض كمشكلة جمركية ويشترون طريقهم حول كل حالة عبر مخلّص جمركي. بينما الرفض مشكلة جودة بيانات، وهو مرئي قبل أسابيع من إبحار الحاوية، وهو الحفنة نفسها من أنماط الفشل تتكرر: رموز بدقة خاطئة، وشهادات تنتهي أثناء الرحلة، وفواتير توقفت عن مطابقة التسجيلات حين أعاد أحدهم تسمية منتج.
كل واحد من هذه قابل للفحص في بضع مئات من أسطر TypeScript على بيانات تملكها بالفعل.
إن كنت تدير عملية استيراد سعودية فوق نظام ERP لم يُصمَّم يومًا لامتثال المواصفات السعودية، وتريد طبقة تحقق موصولة بالأنظمة التي تشغّلها فعلًا بدل سكربت يصونه أحدهم على حاسوبه المحمول — أخبرنا كيف يبدو كتالوجك. سنقول لك بصراحة أي حالات الرفض لديك قابلة للمنع وأيها ليست كذلك.