ابحث عن أي شيء يخصّ نظام حماية الأجور السعودي، وستحصل على الصفحة نفسها عشرين مرة: سجّل الدخول إلى مدد، اختر المنشأة، اضغط رفع. كل النتائج مكتوبة لمن يضغط الأزرار في المنصة. ولا واحدة منها مكتوبة لمن عليه أن يُنتج الملف.
في هذه الفجوة تحديدًا تعيش المخالفات. الرواتب تُدفع بشكل صحيح، والتحويل ينفّذ، ونسبة الالتزام تنخفض رغم ذلك — لأن الملف الذي وصف تلك الرواتب اختلف مع سجل المنشأة بطريقة لم يفحصها أحد قبل الرفع. وحين يصل الرفض تكون دورة الرواتب قد أُغلقت ونافذة التصحيح تضيق.
هذا الدرس يبني ما ينبغي أن يقف بين نظام الموارد البشرية وزر الرفع: خدمة TypeScript تجمّع ملف الأجور من بيانات رواتبك، وتتحقق من كل سجل وفق القواعد التي تسبّب الرفض فعليًا، وتطابق الأرقام مع بيانات العقود والتسجيل، وتنتج تقريرًا يسمّي بالضبط أي صف موظف سيفشل ولماذا.
ملاحظة حول مواصفة الملف. ترتيب الحقول والفاصل وشكل رأس الملف تختلف بين البنوك، وبين قناة البنك ومنصة مدد. لا توجد مواصفة عامة واحدة على مستوى البايت تبقى صحيحة في كل الحالات. لذلك لا نثبّت واحدة بالكود. نبني مولّدًا قائمًا على المخطط، يكون فيه تخطيط الملف مجرد إعداد تملؤه من قالب بنكك، بينما يبقى محرّك التحقق — وهو الجزء الثمين — كما هو مهما تغيّر التخطيط. تحقّق دائمًا من ترتيب الحقول مقابل القالب الذي زوّدك به البنك قبل الإنتاج.
المتطلبات المسبقة
- Node.js إصدار 20 أو أحدث
- أساسيات TypeScript — ستظهر هنا الأنواع العامة والاتحادات المميّزة
- إلمام بمكتبة Zod أو ما يشابهها
- الوصول إلى قالب ملف الأجور من بنكك أو من مدد (لإعداد التخطيط)
- بيانات رواتب قابلة للتصدير: معرّفات الموظفين، الآيبان، مكوّنات الراتب
ما الذي ستبنيه
حزمة من أربع طبقات، كل واحدة قابلة للاختبار وحدها:
- سجل رواتب قياسي — نموذج مجالك، مستقل عمدًا عن أي صيغة ملف
- ملف تعريف التخطيط — إعداد تصريحي يصف صيغة ملف بنك واحد
- محرّك التحقق — القواعد التي تتنبأ بالرفض، بما فيها المطابقة عبر المصادر
- الكاتب والتقرير — الملف النصي نفسه، مع قائمة إخفاقات مقروءة للبشر
الترتيب مقصود. معظم التطبيقات الداخلية تبدأ من الطبقة الرابعة، فتكتب قالب نصوص يُخرج ملفًا، ثم تكتشف مشكلة التحقق بعد ست إشعارات مخالفة.
الخطوة 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رمز الخروج هو بيت القصيد. حين تُربط بمهمة مجدولة، ترفض هذه الأداة تسليم ملف معطوب لأي أحد، وتخبر مسؤول الرواتب بما يجب إصلاحه بينما نافذة التصحيح ما تزال مفتوحة.
الخطوة 10: ساعة رفع الملف
كل ما سبق يجيب عن سؤال واحد: هل هذا الملف صحيح؟ والوزارة تسأل سؤالًا ثانيًا لا يجيب عنه أي مدقّق: متى وصل؟
تذكر صفحة برنامج حماية الأجور على موقع وزارة الموارد البشرية والتنمية الاجتماعية أن انضباط صاحب العمل يُحسب «بناء على عدد أشهر تأخير رفع ملف صرف الأجور»، وأن التأخر يُحتسب من تاريخ استحقاق الأجور. والصفحة نفسها تنشر السلّم:
| مدة تأخر رفع الملف | ما يترتب بحسب صفحة البرنامج |
|---|---|
| شهر | جدولة زيارة تفتيشية للمنشأة |
| شهران | إيقاف جميع الخدمات ما عدا إصدار وتجديد رخص العمل |
| ثلاثة أشهر | إيقاف جميع الخدمات، ويُسمح للعمالة بنقل خدماتها إلى صاحب عمل آخر دون موافقة المنشأة، حتى لو لم تنتهِ رخصة العمل |
وثلاث عبارات أخرى من الوزارة تحدّد نموذج البيانات:
- صاحب العمل الذي سدّد في الوقت ورفع الملف متأخرًا يتعرّف النظام على سداده، شرط ألا يتأخر رفع ملف كل شهر عن ثلاثة أشهر. بعد ذلك يُقرأ الشهر المدفوع كأنه غير مدفوع.
- الملف الذي لا يتبع المواصفات يُرفض بشكل كامل. والرفع المرفوض ليس رفعًا.
- منذ 1 مارس 2025 صارت المدة المسموحة لرفع الملف في منصة مدد 30 يومًا بدلًا من 60، بحسب إعلان الوزارة.
الجانب النظامي، ومعه المادتان 90 و94 من نظام العمل، في مقال تأخير الرواتب في السعودية: الساعة تبدأ من ملف الأجور. وما يلي هو الجزء الذي يملكه المهندس: سجلّ فيه ثلاثة تواريخ لكل شهر راتب، في حين تحتفظ أغلب أنظمة الرواتب بتاريخ واحد.
// src/clock.ts
/** The ladder on the ministry's Wage Protection Program page, worst last. */
export const STAGES = [
"within_window",
"inspection",
"services_suspended",
"transfer_without_consent",
] as const;
export type Stage = (typeof STAGES)[number];
export interface PeriodEntry {
/** Salary month, as YYYY-MM. Same value as PayrollBatch.period. */
period: string;
/** ISO date the wages for this period fall due under the contracts. */
dueDate: string;
/** ISO date the transfer left the bank, or null if it has not. */
paidOn: string | null;
/** ISO date the wage file was ACCEPTED, or null. A rejected upload is null. */
acceptedOn: string | null;
}
export interface ClockOptions {
/** Length of one "month of delay" in days. See the note on this reading. */
monthDays: number;
/** Day after the due date on which an open period starts to alert. */
alertDay: number;
}
export const DEFAULT_CLOCK: ClockOptions = { monthDays: 30, alertDay: 20 };
export interface PeriodStatus {
period: string;
/** True while no accepted file exists for the period. */
open: boolean;
/** Days from the due date to the accepted file, or to `asOf` if open. */
daysSinceDue: number;
stage: Stage;
/** First day of each later stage, as ISO dates. */
inspectionFrom: string;
suspensionFrom: string;
transferFrom: string;
/** True when the transfer itself was on or before the due date. */
paidOnTime: boolean;
/** Paid on time AND the file landed before the three-month mark. */
paymentRecognised: boolean;
/** Open, and at or past the alert day. */
alert: boolean;
}
const DAY_MS = 86_400_000;
function toUtc(iso: string): number {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) throw new Error(`Not an ISO date: ${iso}`);
const ms = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
if (new Date(ms).toISOString().slice(0, 10) !== iso) {
throw new Error(`Not a calendar date: ${iso}`);
}
return ms;
}
function addDays(iso: string, days: number): string {
return new Date(toUtc(iso) + days * DAY_MS).toISOString().slice(0, 10);
}
function daysBetween(from: string, to: string): number {
return Math.round((toUtc(to) - toUtc(from)) / DAY_MS);
}
export function stageFor(daysSinceDue: number, monthDays: number): Stage {
const monthsLate = Math.floor(Math.max(0, daysSinceDue) / monthDays);
return STAGES[Math.min(monthsLate, STAGES.length - 1)]!;
}
export function assessPeriod(
entry: PeriodEntry,
asOf: string,
options: ClockOptions = DEFAULT_CLOCK,
): PeriodStatus {
const { monthDays, alertDay } = options;
if (!Number.isInteger(monthDays) || monthDays < 1) {
throw new Error("monthDays must be a positive integer");
}
if (entry.acceptedOn !== null && daysBetween(entry.acceptedOn, asOf) < 0) {
throw new Error(`${entry.period}: file accepted after the as-of date`);
}
const open = entry.acceptedOn === null;
const daysSinceDue = daysBetween(entry.dueDate, entry.acceptedOn ?? asOf);
const paidOnTime =
entry.paidOn !== null && daysBetween(entry.paidOn, entry.dueDate) >= 0;
return {
period: entry.period,
open,
daysSinceDue,
stage: stageFor(daysSinceDue, monthDays),
inspectionFrom: addDays(entry.dueDate, monthDays),
suspensionFrom: addDays(entry.dueDate, 2 * monthDays),
transferFrom: addDays(entry.dueDate, 3 * monthDays),
paidOnTime,
paymentRecognised: paidOnTime && !open && daysSinceDue < 3 * monthDays,
alert: open && daysSinceDue >= alertDay,
};
}
export interface ClockReport {
asOf: string;
/** Worst stage among periods that still have no accepted file. */
liveStage: Stage;
/** Worst first, then oldest first. */
periods: PeriodStatus[];
}
export function runClock(
register: PeriodEntry[],
asOf: string,
options: ClockOptions = DEFAULT_CLOCK,
): ClockReport {
const seen = new Set<string>();
for (const e of register) {
if (seen.has(e.period)) throw new Error(`Duplicate period ${e.period}`);
seen.add(e.period);
}
const rank = (s: Stage) => STAGES.indexOf(s);
const periods = register
.map((e) => assessPeriod(e, asOf, options))
.sort((a, b) => rank(b.stage) - rank(a.stage) || a.period.localeCompare(b.period));
const live = periods.filter((p) => p.open);
const liveStage = live.reduce<Stage>(
(worst, p) => (rank(p.stage) > rank(worst) ? p.stage : worst),
"within_window",
);
return { asOf, liveStage, periods };
}خمسة قرارات في هذا الملف تستحق الدفاع عنها.
dueDate مُدخل، ولا يُشتق أبدًا. المادة 90 تحدّد الدورية، مرة في الشهر لذوي الأجور الشهرية، ولا تذكر اليوم. اليوم يأتي من عقودك. والمحرّك الذي يفترض آخر يوم في الشهر يخطئ مع كل منشأة تصرف يوم 25، ويخطئ في الاتجاه الذي يخفي التأخر.
acceptedOn لا uploadedOn. ما دام الملف غير المطابق يُرفض بشكل كامل، فمحاولة اليوم 28 التي رُدّت لا توقف الساعة. لا تكتب التاريخ في السجلّ إلا حين تؤكد القناة القبول. ولهذا أيضًا يوضع المدقّق الذي بنيناه في الخطوات 4 إلى 6 قبل الرفع: هو ما يجعل المحاولة الأولى محسوبة.
monthDays قراءتنا، ولهذا جعلناه خيارًا. صفحة البرنامج تعدّ بالأشهر ولا تعرّف الشهر. وإعلان الوزارة نفسه يساوي بين الشهر وثلاثين يومًا، فالقيمة الافتراضية 30 تُحسب من تاريخ الاستحقاق. ويبقى يومان على الحدّ لا ينسبهما أي نص منشور: اليوم 30 نفسه (الإعلان يسمح بثلاثين يومًا، والصفحة تجدول زيارة تفتيشية عند شهر) واليوم 90 في التعرّف على السداد في وقته. المحرّك يعامل الاثنين كتأخر. وإن أظهرت مدد لمنشأتك شيئًا آخر فمدد هي الصواب: غيّر الخيار لا الشيفرة.
لا مبالغ بالريال. ثلاثة أرقام مختلفة للغرامة متداولة في تأخير الأجور، ولكل رقم مصدره، وجدول المخالفات أُعيد تصنيفه في 2026. المحرّك يعيد تواريخ ومراحل. اترك المبلغ لمن يقرأ الجدول الساري.
liveStage ينظر إلى الفترات المفتوحة فقط. الشهر الذي وصل ملفه متأخرًا يبقى في التقرير كتاريخ، لأنه ما سيراه المفتش. والأشهر التي لا ملف مقبولًا لها هي وحدها التي ما زالت تتحرك. والصفحة لا تذكر متى يُرفع الإيقاف بعد قبول الملف الناقص، فالمحرّك لا يدّعي معرفة ذلك.
وانتبه إلى ما لا يفعله paidOnTime: لا يغفر تحويلًا متأخرًا لأن الملف كان سريعًا. الملف المقبول في اليوم 5 الذي يذكر تحويلًا تمّ في اليوم 4 هو تأخير صرف موثّق.
والسجلّ نفسه مستند JSON صغير يكتب فيه إقفال الرواتب عندك:
[
{ "period": "2026-05", "dueDate": "2026-05-31", "paidOn": "2026-05-28", "acceptedOn": "2026-09-10" },
{ "period": "2026-06", "dueDate": "2026-06-30", "paidOn": "2026-06-29", "acceptedOn": "2026-07-06" },
{ "period": "2026-07", "dueDate": "2026-07-31", "paidOn": "2026-07-30", "acceptedOn": null },
{ "period": "2026-08", "dueDate": "2026-08-31", "paidOn": "2026-08-28", "acceptedOn": null },
{ "period": "2026-09", "dueDate": "2026-09-30", "paidOn": "2026-09-29", "acceptedOn": null }
]ونقطة الدخول تأخذ تاريخ المعاينة كوسيط، حتى تعطي إعادة التشغيل عن يوم الثلاثاء الماضي جواب الثلاثاء الماضي:
// src/clock-cli.ts
import { readFileSync } from "node:fs";
import { runClock, type PeriodEntry } from "./clock.js";
const [, , registerPath, asOf] = process.argv;
if (!registerPath || !asOf) {
console.error("usage: tsx src/clock-cli.ts <register.json> <YYYY-MM-DD>");
process.exit(2);
}
const register = JSON.parse(readFileSync(registerPath, "utf8")) as PeriodEntry[];
const report = runClock(register, asOf);
for (const p of report.periods) {
if (!p.open && p.stage === "within_window") continue;
const state = p.open ? "OPEN " : "closed";
console.log(
`${p.period} ${state} day ${String(p.daysSinceDue).padStart(3)} ${p.stage}` +
(p.open ? ` (suspension from ${p.suspensionFrom})` : ""),
);
}
console.log(`Live stage: ${report.liveStage}`);
const quiet = report.liveStage === "within_window" && !report.periods.some((p) => p.alert);
process.exit(quiet ? 0 : 1);شغّله:
npx tsx src/clock-cli.ts data/register.json 2026-10-092026-05 closed day 102 transfer_without_consent
2026-07 OPEN day 70 services_suspended (suspension from 2026-09-29)
2026-08 OPEN day 39 inspection (suspension from 2026-10-30)
2026-09 OPEN day 9 within_window (suspension from 2026-11-29)
Live stage: services_suspendedمايو مُقفل وما زال في القائمة: دُفع في الوقت، وقُبل ملفه في اليوم 102، فلم يُتعرَّف على السداد. يوليو دُفع قبل سبعين يومًا وهو في مرحلة الإيقاف منذ عشرة أيام. وأغسطس يلحق به في 30 أكتوبر. ولا واحد من الثلاثة إخفاق في الرواتب. كل راتب في هذا السجلّ خرج من البنك قبل تاريخ استحقاقه.
والاختبارات تثبّت الحدود، لأن القراءات تسكن عند الحدود:
// tests/clock.test.ts
import { describe, it, expect } from "vitest";
import { assessPeriod, runClock, stageFor } from "../src/clock.js";
const august = {
period: "2026-08",
dueDate: "2026-08-31",
paidOn: "2026-08-28",
acceptedOn: null,
};
describe("upload clock", () => {
it("walks the ladder on the published boundaries", () => {
expect(stageFor(29, 30)).toBe("within_window");
expect(stageFor(30, 30)).toBe("inspection");
expect(stageFor(59, 30)).toBe("inspection");
expect(stageFor(60, 30)).toBe("services_suspended");
expect(stageFor(90, 30)).toBe("transfer_without_consent");
expect(stageFor(400, 30)).toBe("transfer_without_consent");
});
it("treats a paid month with no file as an open, alerting period", () => {
const s = assessPeriod(august, "2026-09-20");
expect(s.daysSinceDue).toBe(20);
expect(s.stage).toBe("within_window");
expect(s.alert).toBe(true);
expect(s.paidOnTime).toBe(true);
expect(s.paymentRecognised).toBe(false);
expect(s.inspectionFrom).toBe("2026-09-30");
expect(s.suspensionFrom).toBe("2026-10-30");
expect(s.transferFrom).toBe("2026-11-29");
});
it("recognises an on-time payment only if the file lands before day 90", () => {
const at89 = assessPeriod({ ...august, acceptedOn: "2026-11-28" }, "2026-12-15");
const at90 = assessPeriod({ ...august, acceptedOn: "2026-11-29" }, "2026-12-15");
expect(at89.paymentRecognised).toBe(true);
expect(at90.paymentRecognised).toBe(false);
expect(at90.stage).toBe("transfer_without_consent");
});
it("does not let an early file hide a late transfer", () => {
const s = assessPeriod(
{ ...august, paidOn: "2026-09-04", acceptedOn: "2026-09-05" },
"2026-09-20",
);
expect(s.stage).toBe("within_window");
expect(s.paidOnTime).toBe(false);
expect(s.paymentRecognised).toBe(false);
});
it("reports the live stage from open periods only", () => {
const report = runClock(
[
{ period: "2026-05", dueDate: "2026-05-31", paidOn: "2026-05-28", acceptedOn: "2026-09-10" },
{ period: "2026-07", dueDate: "2026-07-31", paidOn: "2026-07-30", acceptedOn: null },
august,
],
"2026-10-09",
);
expect(report.periods.map((p) => p.period)).toEqual(["2026-05", "2026-07", "2026-08"]);
expect(report.periods[0]?.stage).toBe("transfer_without_consent");
expect(report.liveStage).toBe("services_suspended");
});
it("refuses bad input instead of guessing", () => {
expect(() => assessPeriod({ ...august, dueDate: "2026-02-30" }, "2026-09-20")).toThrow();
expect(() => assessPeriod({ ...august, acceptedOn: "2026-10-01" }, "2026-09-20")).toThrow();
expect(() => runClock([august, august], "2026-09-20")).toThrow();
});
});جدوِل الساعة يوميًا، بمعزل عن التشغيل الشهري للملف. ومع alertDay عند 20 تبدأ بإفشال المهمة قبل عشرة أيام من مرحلة الزيارة التفتيشية، وهو الوقت اللازم لإصلاح ملف مرفوض وقبول محاولة ثانية.
اختبار التنفيذ
// 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");
});بعد نجاح جميع الاختبارات، مرِّر ملفًا حقيقيًا على تنفيذ مستقل قبل إرساله إلى البنك. أداة فحص ملف حماية الأجور المجانية تتحقّق من رقم التحقّق في الآيبان وأرقام الهويات وتطابق صافي الراتب مع مكوّناته داخل المتصفّح — ولا يُرفع الملف إلى أي خادم — بينما يمنحك نموذج ملف حماية الأجور ملف CSV صحيحًا تقارن به مخرجاتك حين تشكّ في إعداد التخطيط لا في البيانات.
حل المشكلات
البنك يرفض الملف دون تفسير. غالبًا هو ملف التخطيط لا البيانات. قارن ملفك المولَّد مع القالب النموذجي من البنك بايتًا ببايت — افحص الفاصل، والسطر الأخير، ووجود صف الرأس من عدمه.
الأسماء العربية تعود مشوّهة. عدم تطابق في الترميز. تأكد ممّا تتوقعه القناة واضبط encoding في ملف التعريف صراحة.
المبالغ تختلف بكسور صغيرة. شيء في المنبع ينتج أعدادًا عشرية. حوّل إلى هللات صحيحة عند الحدّ الذي تدخل منه بيانات الرواتب إلى نظامك، لا بعد ذلك.
كل شيء يجتاز التحقق ونسبة الالتزام تنخفض رغم ذلك. الملف قُبل، لكن سجل المنشأة يختلف — موظفون مسجّلون تحت مكتب عمل آخر، أو انقسام منشأة تعرفه الموارد البشرية ولا يعرفه سجل الوزارة. هذه منطقة WRONG_ESTABLISHMENT وNOT_IN_REFERENCE، ولهذا صُنّفت أخطاءً لا تحذيرات.
الخدمات موقوفة وكل الرواتب مدفوعة. هناك شهر بلا ملف مقبول. شغّل ساعة الخطوة 10 على آخر ست فترات وابحث عن فترة مفتوحة، أو فترة رُفض رفعها الأول ولم تُعَد المحاولة.
الخطوات التالية
- أضف لقطة للفترة السابقة كي يرصد المحرّك انخفاض الرواتب بين الأشهر، لأنه يُقرأ كنقص في الدفع
- احفظ كل تشغيل مع نتائجه — تكرار الرمز نفسه شهريًا يعني عملية منبع معطلة، لا خطأ إدخال بيانات
- اعرضها كنقطة نهاية داخلية كي يستدعيها نظام الموارد البشرية قبل إغلاق دورة الرواتب
- وسّع المجموعة المرجعية لتشمل بيانات التسجيل، فتظهر تعارضات المنشأة قبل الرواتب لا بعد الرفع
قراءات ذات صلة على الموقع:
- مخالفات حماية الأجور في السعودية مشكلة بيانات لا مشكلة رواتب — النسخة الإدارية من هذه الحجة، لمن يعتمد الميزانية
- تأخير الرواتب في السعودية: الساعة تبدأ من ملف الأجور — الساعات الثلاث وراء الخطوة 10، وأيّها توقف الخدمات
- بناء تكامل NPHIES بمعيار FHIR في TypeScript — النمط نفسه مطبّقًا على مطالبات القطاع الصحي السعودي
- Zod v4 للتحقق من المخططات في Next.js — تعمّق في مكتبة التحقق المستخدمة هنا
- فخ الـ ERP: التكامل لا الاستبدال — لماذا نادرًا ما يكون الحل لإخفاق التزام هو نظام جديد
الخاتمة
ملف الأجور ليس مشكلة رواتب. إنه مشكلة تكامل ترتدي زيّ الرواتب: نظامان يحتفظان بسجلات متداخلة، أحدهما مرجعي، وموعد شهري يعاقب أي اختلاف بينهما.
كل ما في هذا الدرس ينبع من هذا التأطير. نمذج الرواتب لا الملف. ادفع صيغة البنك إلى كائن إعداد واحد. أنفق جهدك الهندسي على طبقة المطابقة، لأن الرفوضات تأتي من هناك. وافحص الموظفين الغائبين عن الملف، لا الموجودين فيه فقط.
النتيجة بناء يستغرق أيامًا قليلة ويطفئ حريقًا شهريًا متكررًا — إضافة إلى تقرير يخبر مسؤول الرواتب بما يجب إصلاحه بينما الوقت ما يزال يتّسع.
إن كنت تحمل مخالفات حماية أجور لا تستطيع تفسيرها، فالتشخيص عادةً قصير: صدّر شهرًا من بيانات الرواتب، وصدّر سجلات العقود، وانظر أي الصفوف يختلف. تحدّث إلينا عن مراجعة مطابقة — سنخبرك إن كانت مشكلتك في الملف، أم في البيانات، أم في سجل المنشأة.