الكتابات/tutorial/2026/08
Tutorial8 أغسطس 2026·30 دقيقة

بناء مولّد ومدقّق لملف حماية الأجور بلغة TypeScript

دليل عملي لتوليد ملف حماية الأجور السعودي والتحقق منه قبل الرفع باستخدام TypeScript. بناء كاتب ملفات قائم على المخطط، ومدقّق للآيبان والهوية الوطنية، ومحرّك مطابقة مع بيانات العقود، وتقرير رفض يستطيع فريق المالية التصرّف بناءً عليه.

ابحث عن أي شيء يخصّ نظام حماية الأجور السعودي، وستحصل على الصفحة نفسها عشرين مرة: سجّل الدخول إلى مدد، اختر المنشأة، اضغط رفع. كل النتائج مكتوبة لمن يضغط الأزرار في المنصة. ولا واحدة منها مكتوبة لمن عليه أن يُنتج الملف.

في هذه الفجوة تحديدًا تعيش المخالفات. الرواتب تُدفع بشكل صحيح، والتحويل ينفّذ، ونسبة الالتزام تنخفض رغم ذلك — لأن الملف الذي وصف تلك الرواتب اختلف مع سجل المنشأة بطريقة لم يفحصها أحد قبل الرفع. وحين يصل الرفض تكون دورة الرواتب قد أُغلقت ونافذة التصحيح تضيق.

هذا الدرس يبني ما ينبغي أن يقف بين نظام الموارد البشرية وزر الرفع: خدمة TypeScript تجمّع ملف الأجور من بيانات رواتبك، وتتحقق من كل سجل وفق القواعد التي تسبّب الرفض فعليًا، وتطابق الأرقام مع بيانات العقود والتسجيل، وتنتج تقريرًا يسمّي بالضبط أي صف موظف سيفشل ولماذا.

ملاحظة حول مواصفة الملف. ترتيب الحقول والفاصل وشكل رأس الملف تختلف بين البنوك، وبين قناة البنك ومنصة مدد. لا توجد مواصفة عامة واحدة على مستوى البايت تبقى صحيحة في كل الحالات. لذلك لا نثبّت واحدة بالكود. نبني مولّدًا قائمًا على المخطط، يكون فيه تخطيط الملف مجرد إعداد تملؤه من قالب بنكك، بينما يبقى محرّك التحقق — وهو الجزء الثمين — كما هو مهما تغيّر التخطيط. تحقّق دائمًا من ترتيب الحقول مقابل القالب الذي زوّدك به البنك قبل الإنتاج.

المتطلبات المسبقة

  • Node.js إصدار 20 أو أحدث
  • أساسيات TypeScript — ستظهر هنا الأنواع العامة والاتحادات المميّزة
  • إلمام بمكتبة Zod أو ما يشابهها
  • الوصول إلى قالب ملف الأجور من بنكك أو من مدد (لإعداد التخطيط)
  • بيانات رواتب قابلة للتصدير: معرّفات الموظفين، الآيبان، مكوّنات الراتب

ما الذي ستبنيه

حزمة من أربع طبقات، كل واحدة قابلة للاختبار وحدها:

  1. سجل رواتب قياسي — نموذج مجالك، مستقل عمدًا عن أي صيغة ملف
  2. ملف تعريف التخطيط — إعداد تصريحي يصف صيغة ملف بنك واحد
  3. محرّك التحقق — القواعد التي تتنبأ بالرفض، بما فيها المطابقة عبر المصادر
  4. الكاتب والتقرير — الملف النصي نفسه، مع قائمة إخفاقات مقروءة للبشر

الترتيب مقصود. معظم التطبيقات الداخلية تبدأ من الطبقة الرابعة، فتكتب قالب نصوص يُخرج ملفًا، ثم تكتشف مشكلة التحقق بعد ست إشعارات مخالفة.

الخطوة 1: تجهيز المشروع

mkdir wps-toolkit && cd wps-toolkit
npm init -y
npm install zod
npm install -D typescript tsx vitest @types/node
npx tsc --init

اضبط المترجم بصرامة كافية حتى لا تتعفّن معالجة المبالغ:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

خيار noUncheckedIndexedAccess أهم مما يبدو. معظم أخطاء ملفات الأجور هي فهرسة مصفوفة على عمود لم يكن موجودًا أصلًا.

الخطوة 2: نمذجة السجل القياسي

لا تنمذج الملف. نمذج الرواتب. الملف إسقاط للرواتب، وإذا عكست هذه العلاقة انتهى بك الأمر ببنية بيانات خاصة ببنك واحد تتسرّب عبر شيفرتك كلها.

// src/domain.ts
import { z } from "zod";
 
/** تُخزَّن المبالغ بالهللات (وحدات صغرى صحيحة) — لا أعداد عشرية أبدًا. */
export const Halalas = z.number().int().nonnegative();
 
export const PayrollRecord = z.object({
  /** رقم الإقامة لغير السعوديين، والهوية الوطنية للسعوديين. 10 أرقام. */
  nationalId: z.string().regex(/^\d{10}$/),
  /** الاسم كما هو مسجّل لدى المنشأة، لا اسم الشهرة. */
  fullName: z.string().min(1).max(100),
  /** آيبان سعودي — 24 محرفًا ببادئة SA. */
  iban: z.string().regex(/^SA\d{22}$/),
  basicSalary: Halalas,
  housingAllowance: Halalas,
  otherAllowances: Halalas,
  deductions: Halalas,
  /** ما خرج فعليًا من الحساب، بالهللات. */
  netPaid: Halalas,
  /** تاريخ التحويل بصيغة ISO. */
  paymentDate: z.string().date(),
  /** أيام العمل الفعلية في الفترة — تحكم حالات الراتب الجزئي. */
  workedDays: z.number().int().min(0).max(31),
});
 
export type PayrollRecord = z.infer<typeof PayrollRecord>;
 
export const PayrollBatch = z.object({
  /** معرّف المنشأة لدى الوزارة (مكتب العمل والتسلسل). */
  establishmentId: z.string().min(1),
  /** رقم السجل التجاري الذي يُرفع الملف تحته. */
  crNumber: z.string().regex(/^\d{10}$/),
  /** شهر الاستحقاق بصيغة YYYY-MM. */
  period: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/),
  bankCode: z.string().min(1),
  records: z.array(PayrollRecord).min(1),
});
 
export type PayrollBatch = z.infer<typeof PayrollBatch>;

قراران هنا يستحقان الدفاع.

المبالغ كأعداد صحيحة بالهللات. ملفات الأجور تُقارن بالتحويلات البنكية حتى الهللة. تمثيل عشري للقيمة 4,733.15 سينتهي يومًا ما إلى 4733.1499999999996 وينتج فارقًا لا يستطيع أحد تفسيره. خزّن الوحدات الصغرى كأعداد صحيحة، ونسّقها عند الحافة فقط.

netPaid كحقل مستقل لا كقيمة محسوبة. الإغراء أن تشتقّه. لا تفعل. الهدف كله من طبقة المطابقة هو مقارنة ما تقول إنك دفعته بما حسبت أنه يجب دفعه. وإن اشتققت أحدهما من الآخر، استحال أن يختلفا، وتكون قد دمّرت الإشارة التي بنيت النظام لالتقاطها.

الخطوة 3: وصف تخطيط الملف كإعداد

هنا يذهب كل ما يخصّ البنك تحديدًا، ولا مكان له سواه.

// src/layout.ts
import type { PayrollBatch, PayrollRecord } from "./domain.js";
 
export type FieldSource =
  | { kind: "record"; render: (r: PayrollRecord) => string }
  | { kind: "batch"; render: (b: PayrollBatch) => string }
  | { kind: "literal"; value: string };
 
export interface FieldSpec {
  name: string;
  source: FieldSource;
  /** للتخطيطات ثابتة العرض فقط؛ تُترك فارغة للملفات المفصولة بفواصل. */
  width?: number;
  pad?: "left" | "right";
}
 
export interface LayoutProfile {
  id: string;
  delimiter: string;
  lineEnding: "\r\n" | "\n";
  encoding: "utf8" | "latin1";
  /** بعض القنوات تريد صف رأس، وبعضها يرفضه تمامًا. */
  headerFields?: FieldSpec[];
  detailFields: FieldSpec[];
  /** صف ختامي بعدد السجلات والمجاميع الرقابية، عند الحاجة. */
  trailerFields?: FieldSpec[];
}

عندها يصبح ملف التعريف الفعلي توثيقًا لقالب بنكك:

// src/profiles/generic-delimited.ts
import type { LayoutProfile } from "../layout.js";
 
const halalasToRiyals = (h: number) => (h / 100).toFixed(2);
 
export const genericDelimited: LayoutProfile = {
  id: "generic-delimited-v1",
  delimiter: ",",
  lineEnding: "\r\n",
  encoding: "utf8",
  headerFields: [
    { name: "recordType", source: { kind: "literal", value: "HDR" } },
    { name: "establishmentId", source: { kind: "batch", render: (b) => b.establishmentId } },
    { name: "crNumber", source: { kind: "batch", render: (b) => b.crNumber } },
    { name: "period", source: { kind: "batch", render: (b) => b.period.replace("-", "") } },
    { name: "bankCode", source: { kind: "batch", render: (b) => b.bankCode } },
    { name: "recordCount", source: { kind: "batch", render: (b) => String(b.records.length) } },
  ],
  detailFields: [
    { name: "recordType", source: { kind: "literal", value: "DTL" } },
    { name: "nationalId", source: { kind: "record", render: (r) => r.nationalId } },
    { name: "fullName", source: { kind: "record", render: (r) => r.fullName } },
    { name: "iban", source: { kind: "record", render: (r) => r.iban } },
    { name: "basicSalary", source: { kind: "record", render: (r) => halalasToRiyals(r.basicSalary) } },
    { name: "housingAllowance", source: { kind: "record", render: (r) => halalasToRiyals(r.housingAllowance) } },
    { name: "otherAllowances", source: { kind: "record", render: (r) => halalasToRiyals(r.otherAllowances) } },
    { name: "deductions", source: { kind: "record", render: (r) => halalasToRiyals(r.deductions) } },
    { name: "netPaid", source: { kind: "record", render: (r) => halalasToRiyals(r.netPaid) } },
    { name: "paymentDate", source: { kind: "record", render: (r) => r.paymentDate.replaceAll("-", "") } },
    { name: "workedDays", source: { kind: "record", render: (r) => String(r.workedDays) } },
  ],
};

عدّل قائمة الحقول وترتيبها وفق قالب بنكك الفعلي قبل الإطلاق. هذا هو الغرض الوحيد من هذا الملف. وحين يغيّر البنك مواصفته — وسيفعل — تعدّل مصفوفة واحدة بدل مطاردة عمليات دمج النصوص عبر المشروع.

الخطوة 4: المدقّقات التي تتنبأ بالرفض

التحقق من المخطط في الخطوة 2 يلتقط أخطاء الشكل. لكنه لا يلتقط ما يتسبب فعلًا في رفض الملفات. هذه تحتاج منطقًا حقيقيًا.

أرقام تحقق الآيبان

آيبان بُدّل فيه رقمان متجاوران يمرّ من التعبير النمطي ويفشل في البنك. خوارزمية mod-97 تلتقطه بشكل حتمي.

// src/validators/iban.ts
 
/** فحص ISO 13616 بـ mod-97. يعيد true حين تكون أرقام التحقق متسقة ذاتيًا. */
export function isValidIban(iban: string): boolean {
  const clean = iban.replace(/\s+/g, "").toUpperCase();
  if (clean.length < 15 || clean.length > 34) return false;
 
  // انقل المحارف الأربعة الأولى إلى النهاية، ثم حوّل الحروف إلى أرقام.
  const rearranged = clean.slice(4) + clean.slice(0, 4);
  const numeric = rearranged.replace(/[A-Z]/g, (c) =>
    String(c.charCodeAt(0) - 55),
  );
 
  // العدد أكبر بكثير من Number.MAX_SAFE_INTEGER، لذا نختزله تدريجيًا.
  let remainder = 0;
  for (const digit of numeric) {
    remainder = (remainder * 10 + Number(digit)) % 97;
  }
  return remainder === 1;
}
 
/** الآيبان السعودي 24 محرفًا بالضبط ويبدأ بـ SA. */
export function isValidSaudiIban(iban: string): boolean {
  const clean = iban.replace(/\s+/g, "").toUpperCase();
  return clean.length === 24 && clean.startsWith("SA") && isValidIban(clean);
}

الاختزال التدريجي مهم. التطبيق الساذج يكتب BigInt(numeric) % 97n، وهو يعمل لكنه يخصّص عددًا ضخمًا من 30 خانة لكل سجل. على ملف من 4,000 موظف يصبح الفرق ملموسًا؛ الحلقة أعلاه لا.

معقولية الهوية الوطنية والإقامة

المعرّفات السعودية تحمل رقم تحقق يُحسب بخوارزمية على نمط Luhn، والرقم الأول يميّز الهوية الوطنية عن الإقامة.

// src/validators/national-id.ts
 
export type IdKind = "national" | "iqama" | "unknown";
 
export function idKind(id: string): IdKind {
  if (!/^\d{10}$/.test(id)) return "unknown";
  if (id.startsWith("1")) return "national";
  if (id.startsWith("2")) return "iqama";
  return "unknown";
}
 
/**
 * رقم تحقق على نمط Luhn تستخدمه أرقام الهوية السعودية.
 * تعامل معه كمرشّح أوّلي يلتقط الأخطاء المطبعية، لا كمرجع يحدّد
 * إن كان الشخص موجودًا. سجلات الوزارة وحدها تجيب عن ذلك.
 */
export function hasValidIdCheckDigit(id: string): boolean {
  if (!/^\d{10}$/.test(id)) return false;
  let sum = 0;
  for (let i = 0; i < 9; i++) {
    const digit = Number(id[i]);
    if (i % 2 === 0) {
      const doubled = digit * 2;
      sum += Math.floor(doubled / 10) + (doubled % 10);
    } else {
      sum += digit;
    }
  }
  const expected = (10 - (sum % 10)) % 10;
  return expected === Number(id[9]);
}

التعليق ليس زينة. رقم التحقق يخبرك أن الرقم كُتب بشكل صحيح. ولا يقول شيئًا عن كون ذلك الشخص مسجّلًا تحت هذه المنشأة — وهذا التعارض تحديدًا من أبرز أسباب رفض السجلات. نعالجه في الخطوة 5.

اتساق الحساب الداخلي

// src/validators/amounts.ts
import type { PayrollRecord } from "../domain.js";
 
export interface AmountIssue {
  code: string;
  message: string;
}
 
export function checkAmounts(r: PayrollRecord): AmountIssue[] {
  const issues: AmountIssue[] = [];
 
  const gross = r.basicSalary + r.housingAllowance + r.otherAllowances;
  const expectedNet = gross - r.deductions;
 
  if (expectedNet !== r.netPaid) {
    issues.push({
      code: "NET_MISMATCH",
      message: `مجموع المكوّنات ${expectedNet / 100} ريال بينما الصافي ${r.netPaid / 100} ريال`,
    });
  }
 
  if (r.deductions > gross) {
    issues.push({
      code: "DEDUCTION_EXCEEDS_GROSS",
      message: "الاستقطاعات تتجاوز الإجمالي للفترة",
    });
  }
 
  if (r.basicSalary === 0 && r.workedDays > 0) {
    issues.push({
      code: "ZERO_BASIC_WITH_WORKED_DAYS",
      message: "الراتب الأساسي صفر رغم وجود أيام عمل",
    });
  }
 
  if (r.netPaid === 0 && r.workedDays > 0) {
    issues.push({
      code: "ZERO_NET_WITH_WORKED_DAYS",
      message: "الصافي صفر لموظف مسجّلة له أيام عمل",
    });
  }
 
  return issues;
}

الصافي الصفري لموظف لديه أيام عمل حالة مشروعة في الإجازات بدون راتب وفي الالتحاق منتصف الشهر. وهو أيضًا ما يبدو عليه فشل ربط غير مقصود. صنّفه تحذيرًا يستلزم تبريرًا مذكورًا، لا خطأً يوقف الملف — والفرق بين الحالتين هو موضوع الخطوة 5.

الخطوة 5: المطابقة — الجزء الذي لا يبنيه أحد

كل ما سبق يتحقق من الملف مقابل نفسه. أما الرفوضات المؤلمة فتأتي من اختلاف الملف مع نظام آخر: العقد المسجّل، أو تسجيل المنشأة، أو ملف الشهر الماضي.

نمذج ذلك كلقطة مرجعية وقارن معها.

// src/reconcile.ts
import type { PayrollBatch, PayrollRecord } from "./domain.js";
 
export interface ContractReference {
  nationalId: string;
  /** الراتب الأساسي التعاقدي بالهللات، كما هو مسجّل. */
  contractedBasic: number;
  contractedHousing: number;
  /** المنشأة التي يُسجَّل الموظف تحتها. */
  establishmentId: string;
  status: "active" | "terminated" | "on_leave";
  /** الآيبان المسجّل لدى المنشأة، إن وُجد. */
  iban?: string;
}
 
export type Severity = "error" | "warning";
 
export interface Finding {
  nationalId: string;
  fullName: string;
  code: string;
  severity: Severity;
  message: string;
}
 
export function reconcile(
  batch: PayrollBatch,
  references: ContractReference[],
): Finding[] {
  const byId = new Map(references.map((c) => [c.nationalId, c]));
  const findings: Finding[] = [];
  const seen = new Set<string>();
 
  const push = (
    r: PayrollRecord,
    code: string,
    severity: Severity,
    message: string,
  ) => findings.push({ nationalId: r.nationalId, fullName: r.fullName, code, severity, message });
 
  for (const r of batch.records) {
    if (seen.has(r.nationalId)) {
      push(r, "DUPLICATE_RECORD", "error", "الموظف مكرر أكثر من مرة في هذا الملف");
      continue;
    }
    seen.add(r.nationalId);
 
    const ref = byId.get(r.nationalId);
    if (!ref) {
      push(r, "NOT_IN_REFERENCE", "error", "لا يوجد سجل عقد لهذا المعرّف");
      continue;
    }
 
    if (ref.establishmentId !== batch.establishmentId) {
      push(
        r,
        "WRONG_ESTABLISHMENT",
        "error",
        `مسجّل تحت ${ref.establishmentId} بينما يُرفع تحت ${batch.establishmentId}`,
      );
    }
 
    if (ref.status === "terminated") {
      push(r, "TERMINATED_EMPLOYEE", "error", "الموظف منتهية خدمته لكنه يظهر في هذه الفترة");
    }
 
    if (ref.iban && ref.iban !== r.iban) {
      push(r, "IBAN_CHANGED", "warning", "الآيبان يختلف عن المسجّل");
    }
 
    // الشهر الكامل يجب أن يطابق العقد؛ الشهر الجزئي لن يطابقه بشكل مشروع.
    const fullMonth = r.workedDays >= 28;
    if (fullMonth && r.basicSalary !== ref.contractedBasic) {
      push(
        r,
        "BASIC_BELOW_CONTRACT",
        r.basicSalary < ref.contractedBasic ? "error" : "warning",
        `الأساسي ${r.basicSalary / 100} ريال مقابل ${ref.contractedBasic / 100} ريال تعاقديًا لشهر كامل`,
      );
    }
 
    if (fullMonth && r.housingAllowance !== ref.contractedHousing) {
      push(r, "HOUSING_MISMATCH", "warning", "بدل السكن يختلف عن المبلغ التعاقدي");
    }
  }
 
  // موظفون متوقّع وجودهم في الملف لكنهم غائبون عنه.
  const filed = new Set(batch.records.map((r) => r.nationalId));
  for (const ref of references) {
    if (ref.status === "active" && ref.establishmentId === batch.establishmentId && !filed.has(ref.nationalId)) {
      findings.push({
        nationalId: ref.nationalId,
        fullName: "(غائب عن الملف)",
        code: "MISSING_ACTIVE_EMPLOYEE",
        severity: "error",
        message: "موظف على رأس العمل بلا سجل في هذه الفترة",
      });
    }
  }
 
  return findings;
}

انتبه للحلقة الأخيرة. كل مدقّق يُبنى داخليًا يفحص الصفوف الموجودة. أما الصفوف الغائبة فهي ما يخفض نسبة الالتزام، لأن موظفًا على رأس العمل بلا سجل أجر يُقرأ كأنه لم يُدفع له. المرور على المجموعة المرجعية بدل الملف هو أعلى فحص قيمةً في هذا الدرس كله.

الخطوة 6: تركيب خط التحقق

// src/validate.ts
import { PayrollBatch } from "./domain.js";
import { isValidSaudiIban } from "./validators/iban.js";
import { hasValidIdCheckDigit, idKind } from "./validators/national-id.js";
import { checkAmounts } from "./validators/amounts.js";
import { reconcile, type ContractReference, type Finding } from "./reconcile.js";
 
export interface ValidationResult {
  ok: boolean;
  errors: Finding[];
  warnings: Finding[];
}
 
export function validateBatch(
  input: unknown,
  references: ContractReference[],
): ValidationResult {
  const parsed = PayrollBatch.safeParse(input);
  if (!parsed.success) {
    return {
      ok: false,
      warnings: [],
      errors: parsed.error.issues.map((i) => ({
        nationalId: "-",
        fullName: "-",
        code: "SCHEMA",
        severity: "error" as const,
        message: `${i.path.join(".")}: ${i.message}`,
      })),
    };
  }
 
  const batch = parsed.data;
  const findings: Finding[] = [];
 
  for (const r of batch.records) {
    const at = (code: string, severity: "error" | "warning", message: string) =>
      findings.push({ nationalId: r.nationalId, fullName: r.fullName, code, severity, message });
 
    if (!isValidSaudiIban(r.iban)) at("INVALID_IBAN", "error", "الآيبان يفشل في فحص mod-97");
    if (!hasValidIdCheckDigit(r.nationalId)) at("INVALID_ID", "error", "المعرّف يفشل في رقم التحقق");
    if (idKind(r.nationalId) === "unknown") at("UNKNOWN_ID_KIND", "warning", "المعرّف ليس هوية وطنية ولا إقامة");
 
    for (const issue of checkAmounts(r)) at(issue.code, "error", issue.message);
  }
 
  findings.push(...reconcile(batch, references));
 
  return {
    ok: !findings.some((f) => f.severity === "error"),
    errors: findings.filter((f) => f.severity === "error"),
    warnings: findings.filter((f) => f.severity === "warning"),
  };
}

الخطوة 7: كتابة الملف

الآن فقط، ولدفعة اجتازت التحقق فقط.

// src/write.ts
import type { LayoutProfile, FieldSpec } from "./layout.js";
import type { PayrollBatch, PayrollRecord } from "./domain.js";
 
function renderField(spec: FieldSpec, batch: PayrollBatch, record?: PayrollRecord): string {
  let value: string;
  switch (spec.source.kind) {
    case "literal":
      value = spec.source.value;
      break;
    case "batch":
      value = spec.source.render(batch);
      break;
    case "record":
      if (!record) throw new Error(`الحقل ${spec.name} يحتاج سجلًا ولم يُمرَّر أي سجل`);
      value = spec.source.render(record);
      break;
  }
 
  if (spec.width === undefined) return value;
  if (value.length > spec.width) {
    throw new Error(`الحقل ${spec.name} بطول ${value.length} محرفًا ويتجاوز العرض ${spec.width}`);
  }
  return spec.pad === "left"
    ? value.padStart(spec.width, "0")
    : value.padEnd(spec.width, " ");
}
 
export function writeWpsFile(batch: PayrollBatch, profile: LayoutProfile): Buffer {
  const rows: string[] = [];
  const join = (specs: FieldSpec[], record?: PayrollRecord) =>
    specs.map((s) => renderField(s, batch, record)).join(profile.delimiter);
 
  if (profile.headerFields) rows.push(join(profile.headerFields));
  for (const record of batch.records) rows.push(join(profile.detailFields, record));
  if (profile.trailerFields) rows.push(join(profile.trailerFields));
 
  const text = rows.join(profile.lineEnding) + profile.lineEnding;
  return Buffer.from(text, profile.encoding);
}

ثلاث تفاصيل تسبّب إخفاقات حقيقية:

نهايات الأسطر. عدة قنوات بنكية ترفض ملفًا بنهايات أسطر يونكس ولا تعطي خطأً مفيدًا. اجعلها إعدادًا، لا نتيجة عرضية للجهاز الذي ولّد الملف.

الترميز. إن احتوى أي اسم على حروف عربية وكانت القناة تتوقع ترميزًا أحادي البايت قديمًا، ستحصل على رموز مشوّهة أو رفض مباشر. تأكد ممّا يتوقعه القالب واضبطه صراحة بدل الاعتماد على افتراضي Node.

رمي خطأ عند تجاوز العرض. الاقتطاع الصامت لاسم كي يتّسع هو الطريقة التي ينتهي بها سجل إلى وصف شخص غير موجود. أخفق بصوت عالٍ عند التوليد، حيث يستطيع أحدهم الإصلاح.

الخطوة 8: تقرير يستطيع فريق المالية التصرّف بناءً عليه

قائمة رموز أخطاء عديمة الفائدة لمن عليه إصلاح البيانات. اجمعها حسب السبب، لا حسب الصف.

// src/report.ts
import type { ValidationResult } from "./validate.js";
 
export function formatReport(result: ValidationResult): string {
  const lines: string[] = [];
  const groups = new Map<string, typeof result.errors>();
 
  for (const f of [...result.errors, ...result.warnings]) {
    const existing = groups.get(f.code) ?? [];
    existing.push(f);
    groups.set(f.code, existing);
  }
 
  const sorted = [...groups.entries()].sort((a, b) => b[1].length - a[1].length);
 
  lines.push(result.ok ? "اجتاز — الملف آمن للتوليد" : "موقوف — يجب حل الأخطاء أولًا");
  lines.push(`${result.errors.length} أخطاء، ${result.warnings.length} تحذيرات`);
  lines.push("");
 
  for (const [code, findings] of sorted) {
    lines.push(`[${findings[0]!.severity.toUpperCase()}] ${code} — ${findings.length} متأثرًا`);
    for (const f of findings.slice(0, 5)) {
      lines.push(`   ${f.nationalId}  ${f.fullName}  —  ${f.message}`);
    }
    if (findings.length > 5) lines.push(`   ... و ${findings.length - 5} غيرهم`);
    lines.push("");
  }
 
  return lines.join("\n");
}

ترتيب المجموعات حسب التكرار مقصود. حين يفشل 340 سجلًا برمز WRONG_ESTABLISHMENT، فهذا فرع واحد مسجّل تحت مكتب عمل خاطئ، لا 340 مشكلة. التجميع يحوّل جدارًا من الضجيج إلى إصلاح واحد.

الخطوة 9: ربط الأجزاء معًا

// src/cli.ts
import { readFileSync, writeFileSync } from "node:fs";
import { validateBatch } from "./validate.js";
import { writeWpsFile } from "./write.js";
import { formatReport } from "./report.js";
import { genericDelimited } from "./profiles/generic-delimited.js";
import { PayrollBatch } from "./domain.js";
 
const [, , batchPath, referencesPath, outPath] = process.argv;
if (!batchPath || !referencesPath || !outPath) {
  console.error("usage: tsx src/cli.ts <batch.json> <references.json> <out.txt>");
  process.exit(2);
}
 
const batchInput = JSON.parse(readFileSync(batchPath, "utf8"));
const references = JSON.parse(readFileSync(referencesPath, "utf8"));
 
const result = validateBatch(batchInput, references);
console.log(formatReport(result));
 
if (!result.ok) {
  console.error("لم يُولَّد الملف. عالج الأخطاء أعلاه وأعد التشغيل.");
  process.exit(1);
}
 
const file = writeWpsFile(PayrollBatch.parse(batchInput), genericDelimited);
writeFileSync(outPath, file);
console.log(`تمت كتابة ${outPath} بحجم ${file.byteLength} بايت`);

شغّلها:

npx tsx src/cli.ts data/august.json data/contracts.json out/wps-2026-08.txt

رمز الخروج هو بيت القصيد. حين تُربط بمهمة مجدولة، ترفض هذه الأداة تسليم ملف معطوب لأي أحد، وتخبر مسؤول الرواتب بما يجب إصلاحه بينما نافذة التصحيح ما تزال مفتوحة.

اختبار التنفيذ

// tests/validators.test.ts
import { describe, it, expect } from "vitest";
import { isValidSaudiIban } from "../src/validators/iban.js";
import { checkAmounts } from "../src/validators/amounts.js";
 
describe("iban", () => {
  it("يرفض طولًا مخالفًا للـ 24 محرفًا", () => {
    expect(isValidSaudiIban("SA038000000060801016751")).toBe(false);
  });
 
  it("يرفض بادئة غير سعودية", () => {
    expect(isValidSaudiIban("GB82WEST12345698765432")).toBe(false);
  });
 
  it("يلتقط تبديل رقمين يمرّ من التعبير النمطي", () => {
    const good = "SA0380000000608010167519";
    const transposed = good.slice(0, 10) + good[11] + good[10] + good.slice(12);
    expect(isValidSaudiIban(good)).not.toBe(isValidSaudiIban(transposed));
  });
});
 
describe("amounts", () => {
  const base = {
    nationalId: "1234567890",
    fullName: "Test",
    iban: "SA0380000000608010167519",
    basicSalary: 500_000,
    housingAllowance: 125_000,
    otherAllowances: 0,
    deductions: 0,
    netPaid: 625_000,
    paymentDate: "2026-08-28",
    workedDays: 30,
  };
 
  it("يمرّر سجلًا متسقًا", () => {
    expect(checkAmounts(base)).toHaveLength(0);
  });
 
  it("يرصد صافيًا لا يطابق مكوّناته", () => {
    const codes = checkAmounts({ ...base, netPaid: 600_000 }).map((i) => i.code);
    expect(codes).toContain("NET_MISMATCH");
  });
});

استخدم معرّفات اصطناعية في الاختبارات. لا تُودِع أبدًا بيانات موظفين حقيقية في المستودع — الملف الذي تولّده هو بالضبط نوع الحمولة التي لا ينبغي أن تنتهي في سجل git.

ثم اختبر طبقة المطابقة على السيناريو الأهم:

it("يلتقط موظفًا على رأس العمل غائبًا عن الملف", () => {
  const result = validateBatch(batchWithoutFatima, contractsIncludingFatima);
  expect(result.errors.map((e) => e.code)).toContain("MISSING_ACTIVE_EMPLOYEE");
});

حل المشكلات

البنك يرفض الملف دون تفسير. غالبًا هو ملف التخطيط لا البيانات. قارن ملفك المولَّد مع القالب النموذجي من البنك بايتًا ببايت — افحص الفاصل، والسطر الأخير، ووجود صف الرأس من عدمه.

الأسماء العربية تعود مشوّهة. عدم تطابق في الترميز. تأكد ممّا تتوقعه القناة واضبط encoding في ملف التعريف صراحة.

المبالغ تختلف بكسور صغيرة. شيء في المنبع ينتج أعدادًا عشرية. حوّل إلى هللات صحيحة عند الحدّ الذي تدخل منه بيانات الرواتب إلى نظامك، لا بعد ذلك.

كل شيء يجتاز التحقق ونسبة الالتزام تنخفض رغم ذلك. الملف قُبل، لكن سجل المنشأة يختلف — موظفون مسجّلون تحت مكتب عمل آخر، أو انقسام منشأة تعرفه الموارد البشرية ولا يعرفه سجل الوزارة. هذه منطقة WRONG_ESTABLISHMENT وNOT_IN_REFERENCE، ولهذا صُنّفت أخطاءً لا تحذيرات.

الخطوات التالية

  • أضف لقطة للفترة السابقة كي يرصد المحرّك انخفاض الرواتب بين الأشهر، لأنه يُقرأ كنقص في الدفع
  • احفظ كل تشغيل مع نتائجه — تكرار الرمز نفسه شهريًا يعني عملية منبع معطلة، لا خطأ إدخال بيانات
  • اعرضها كنقطة نهاية داخلية كي يستدعيها نظام الموارد البشرية قبل إغلاق دورة الرواتب
  • وسّع المجموعة المرجعية لتشمل بيانات التسجيل، فتظهر تعارضات المنشأة قبل الرواتب لا بعد الرفع

قراءات ذات صلة على الموقع:

الخاتمة

ملف الأجور ليس مشكلة رواتب. إنه مشكلة تكامل ترتدي زيّ الرواتب: نظامان يحتفظان بسجلات متداخلة، أحدهما مرجعي، وموعد شهري يعاقب أي اختلاف بينهما.

كل ما في هذا الدرس ينبع من هذا التأطير. نمذج الرواتب لا الملف. ادفع صيغة البنك إلى كائن إعداد واحد. أنفق جهدك الهندسي على طبقة المطابقة، لأن الرفوضات تأتي من هناك. وافحص الموظفين الغائبين عن الملف، لا الموجودين فيه فقط.

النتيجة بناء يستغرق أيامًا قليلة ويطفئ حريقًا شهريًا متكررًا — إضافة إلى تقرير يخبر مسؤول الرواتب بما يجب إصلاحه بينما الوقت ما يزال يتّسع.


إن كنت تحمل مخالفات حماية أجور لا تستطيع تفسيرها، فالتشخيص عادةً قصير: صدّر شهرًا من بيانات الرواتب، وصدّر سجلات العقود، وانظر أي الصفوف يختلف. تحدّث إلينا عن مراجعة مطابقة — سنخبرك إن كانت مشكلتك في الملف، أم في البيانات، أم في سجل المنشأة.