سلسلة تجزئة سعودية بأربعين فرعًا تحمل أربعين رخصة نشاط تجاري على الأقل من منصة بلدي، وأربعين تصريح سلامة من الدفاع المدني، ومعها رخص لوحات ومستودعات وعربات متنقلة. كلها تنتهي. ومعظمها ينتهي بتاريخ محسوب على تقويم أم القرى الهجري، أي أن موعد التجديد يتقدّم نحو أحد عشر يومًا في كل سنة ميلادية.
الشركات تتابع هذا في جدول Excel. الجدول يخزّن تواريخ ميلادية، فيضيف أحدهم 365 يومًا إلى تاريخ العام الماضي، وبعد أحد عشر يومًا من الموعد الذي توقعوه يكون الفرع يعمل برخصة منتهية. والبلدية تغرّم على كل مخالفة، ولها أن تغلق المحل.
هذا الدرس يبني النظام الذي كان يجب أن يكون مكان ذلك الجدول: نظام متابعة يمثّل رخص بلدي تمثيلًا صحيحًا، ويحسب الانتهاء بالتقويم الذي صدرت به الرخصة فعلًا، ويصعّد التجديدات قبل أن تتحول إلى غرامات، ويطابق ما تعتقده مع ما تقوله المنصة.
المتطلبات المسبقة
قبل البدء، تأكّد من توفّر:
- Node.js 20+ (الدرس يعتمد على حزمة ICU الكاملة، وهي مضمّنة افتراضيًا منذ Node 14)
- TypeScript 5.x ومعرفة عملية بها
- إلمام بمكتبة Zod أو ما يماثلها للتحقق وقت التشغيل
- قاعدة بيانات PostgreSQL أو أي مخزن تفضّله — المخطط ينتقل بسهولة
- فهم أساسي لـ cron أو أي مجدول مهام
ولا تحتاج إلى بيانات دخول منصة بلدي لمتابعة الدرس. وهذا بالضبط موضوع الخطوة الأولى.
ما الذي ستبنيه
خدمة من أربعة أجزاء:
- نموذج مجال يغطي أنواع الرخص التي تصدرها بلدي فعليًا، بتواريخ ثنائية التقويم.
- محرك تقويم أم القرى يحوّل في الاتجاهين ويضيف السنوات الهجرية بشكل صحيح.
- آلة حالات للتصعيد تحوّل «الأيام المتبقية» إلى عمل له مالك وإجراء.
- حلقة مطابقة تكشف الانحراف بين سجلّك والواقع.
وفي النهاية تحصل على مكتبة مختبَرة يمكن دمجها في نظام إداري قائم.
الخطوة 1: افهم مع ماذا تتكامل (ومع ماذا لا تتكامل)
هذه الخطوة توفّر عليك شهرًا كاملًا.
منصة بلدي (balady.gov.sa) هي منصة الخدمات الرقمية لوزارة البلديات والإسكان. تُصدر الرخص البلدية وتجدّدها وتلغيها في كل أمانة وبلدية في المملكة. أهم الخدمات التي تعنيك:
| الخدمة | ما تغطيه |
|---|---|
| رخصة نشاط تجاري | التصريح الأساسي لممارسة النشاط في عنوان محدد |
| رخصة بناء | البناء والهدم والترميم |
| تصريح السلامة | يصدر عبر الدفاع المدني، ويُربط مع الرخصة التجارية |
| رخصة لوحة | لوحة المحل الخارجية |
| رخصة عربة متنقلة | عربات الطعام والباعة المتجولون |
| خدمة «رخصي» | عرض حالة الرخص للقراءة فقط داخل المنصة |
وهنا ما لا يكتبه أحد صراحة: منصة بلدي لا تنشر واجهة برمجية عامة للمطورين. لا يوجد تسجيل تطبيقات OAuth، ولا بيئة تجريبية، ولا نطاق api.balady.gov.sa فيه نقطة إصدار رموز وحدود استدعاء. المنصة توثّق البشر عبر بوابة النفاذ الوطني الموحد (عبر أبشر)، وتوثّق المنشآت عبر business.balady.sa.
يترك ذلك ثلاثة مسارات تكامل صادقة:
- إدخال يدوي منظّم مع استيعاب المستندات. يُدخل موظف العمليات كل رخصة مرة واحدة أو يرفع ملفها، فتحلّل أنت الملف وتوحّد بياناته. هذا ما تفعله معظم الشركات فعلًا، وهو المسار الذي يسلكه هذا الدرس.
- وصول مفوَّض عبر مكتب خدمات عامة مرخّص. كثير من المنشآت تدفع أصلًا لمكتب خدمات لتقديم التجديدات، وبعضها يصدّر لك سجل الرخص عند الطلب.
- تكامل حكومي عبر قنوات المنشأة نفسها. المنشآت الكبيرة التي لديها اتفاقية رسمية قد تحصل على تغذية بيانات عبر الوزارة، بالطريقة نفسها التي يُوسَّط بها الوصول إلى التأمينات الاجتماعية ومقيم. وبيانات الدخول ملك المنشأة لا ملكك أنت كمورّد.
لا تبنِ على نقطة نهاية غير موثّقة اكتشفتها في أدوات المطور بالمتصفح. لا يوجد لها أي التزام بالاستقرار، وهي مرتبطة بجلسة بشرية، واستخراج جلسة من النفاذ الوطني الموحد مشكلة امتثال بحد ذاتها. ابنِ السجل، واجعل الخطوة البشرية رخيصة وقابلة للتدقيق بدلًا من ذلك.
هذا القيد نفسه تواجهه مع مقيم، وبدرجة أقل مع التأمينات الاجتماعية. إن سبق أن تكاملت معهما فسيبدو لك الشكل مألوفًا — راجع درس محرك اشتراكات التأمينات الاجتماعية لنمط المطابقة مطبَّقًا على سجل مختلف.
الخطوة 2: نمذجة المجال
ابدأ بالأنواع. وأهم قرار في الدرس كله هنا: خزّن التقويمين معًا، وخزّن أيّهما هو المرجعي.
mkdir balady-tracker && cd balady-tracker
npm init -y
npm install zod
npm install -D typescript tsx vitest @types/node
npx tsc --initأنشئ الملف src/domain.ts:
import { z } from 'zod';
/** التقويم الذي يُعبَّر به نظاميًا عن تاريخ انتهاء الرخصة. */
export type CalendarSystem = 'hijri' | 'gregorian';
export const LicenceKind = z.enum([
'commercial_activity', // رخصة نشاط تجاري
'building_permit', // رخصة بناء
'safety_permit', // تصريح السلامة
'signage', // رخصة لوحة
'mobile_cart', // رخصة عربة متنقلة
]);
export type LicenceKind = z.infer<typeof LicenceKind>;
/** تاريخ هجري بتقويم أم القرى — تاريخ مدني وليس طابعًا زمنيًا. */
export const HijriDate = z.object({
year: z.number().int().min(1300).max(1600),
month: z.number().int().min(1).max(12),
day: z.number().int().min(1).max(30),
});
export type HijriDate = z.infer<typeof HijriDate>;
/**
* السجل التجاري السعودي: عشرة أرقام.
* الرقم الأول يرمز إلى مجموعة المدينة المُصدِرة.
*/
export const CommercialRegistration = z
.string()
.regex(/^[12347]\d{9}$/, 'السجل التجاري عشرة أرقام تبدأ بـ 1 أو 2 أو 3 أو 4 أو 7');
/** أرقام رخص بلدي سلاسل رقمية من عشر خانات. */
export const LicenceNumber = z
.string()
.regex(/^\d{10}$/, 'رقم رخصة بلدي عشرة أرقام بالضبط');
export const Licence = z.object({
id: z.string().uuid(),
kind: LicenceKind,
licenceNumber: LicenceNumber,
commercialRegistration: CommercialRegistration,
/** اسم الفرع مع الأمانة أو البلدية التي أصدرت الرخصة. */
branchName: z.string().min(1),
municipality: z.string().min(1), // مثل "أمانة منطقة الرياض"
/** التقويم الذي طُبع به تاريخ الانتهاء. */
authoritativeCalendar: z.enum(['hijri', 'gregorian']),
/** الحقلان مملوءان دائمًا، وأحدهما مشتق من الآخر. */
expiresOnHijri: HijriDate,
expiresOnGregorian: z.string().date(), // ISO YYYY-MM-DD
/** آخر مرة تأكدنا فيها من الرخصة مقابل المنصة. */
lastVerifiedAt: z.string().datetime().nullable(),
/** من يتابع التجديد. الرخص بلا مالك هي سبب الغرامات. */
ownerEmail: z.string().email(),
});
export type Licence = z.infer<typeof Licence>;تفصيلان يستحقان الدفاع عنهما.
الحقل authoritativeCalendar ليس زينة. رخص النشاط التجاري تصدر وتجدَّد عادة بتواريخ هجرية، بينما بعض التصاريح — ومعظم العقود التي ستقارن بها — ميلادية. إذا وحّدت كل شيء إلى الميلادي عند الإدخال وأهملت أيّهما كان مرجعيًا، فلن تستطيع حساب نافذة التجديد بشكل صحيح في العام التالي. احتفظ به.
حقلا التاريخ مملوءان دائمًا. تفهرس وتستعلم على expiresOnGregorian لأنه ما تفهمه قاعدة بياناتك ومجدولك وموظفوك. أما الحساب فيتم من expiresOnHijri. تخزين واحد فقط واشتقاق الآخر عند القراءة هو الطريق الذي تعود منه ثغرة الأحد عشر يومًا على يد إعادة هيكلة حسنة النية.
الخطوة 3: بناء محرك تقويم أم القرى
هذا هو جوهر الدرس التقني، والجزء الذي تخطئ فيه معظم التطبيقات.
تعتمد المملكة تقويم أم القرى، وهو تقويم هجري جدولي محدد — ليس التقويم الفلكي، وليس الصيغ الحسابية الهجرية المستخدمة في أماكن أخرى. وضبطه مهم: أسلوب «أضف 354 يومًا» خاطئ في نحو نصف السنوات، لأن السنة الهجرية تتناوب بين 354 و355 يومًا.
هذا مدمج في Node عبر ICU. أنشئ src/hijri.ts:
const UMALQURA = 'en-u-ca-islamic-umalqura-nu-latn';
const RIYADH = 'Asia/Riyadh';
const DAY_MS = 86_400_000;
const partsFormatter = new Intl.DateTimeFormat(UMALQURA, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
timeZone: RIYADH,
});
export interface HijriParts {
year: number;
month: number;
day: number;
}
/** تحويل لحظة ميلادية إلى تاريخها المدني بأم القرى بتوقيت الرياض. */
export function toHijri(date: Date): HijriParts {
const parts = Object.fromEntries(
partsFormatter
.formatToParts(date)
.filter((p) => p.type !== 'literal')
.map((p) => [p.type, p.value]),
);
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
};
}
function compareHijri(a: HijriParts, b: HijriParts): number {
return a.year - b.year || a.month - b.month || a.day - b.day;
}أما الاتجاه الأصعب فمختلف. توفّر ICU التحويل من الميلادي إلى الهجري لا العكس. وبدل تضمين جدول بحث لأم القرى سيتقادم مع الوقت، ابحث ثنائيًا داخل التحويل الذي نثق به أصلًا:
/**
* إيجاد التاريخ الميلادي الذي يوافق تاريخ أم القرى `target` بالضبط.
* يُعيد null إذا كان التاريخ غير موجود (مثل اليوم 30 في شهر من 29 يومًا).
*/
export function fromHijri(target: HijriParts): Date | null {
// نقطة بداية من بدء التقويم الهجري (622-07-19م) ومتوسط طول السنة.
const seed = Date.UTC(622, 6, 19) + (target.year - 1) * 354.367 * DAY_MS;
let lo = seed - 60 * DAY_MS;
let hi = seed + 420 * DAY_MS;
while (hi - lo > DAY_MS) {
const mid = lo + Math.floor((hi - lo) / 2 / DAY_MS) * DAY_MS;
if (compareHijri(toHijri(new Date(mid)), target) < 0) {
lo = mid;
} else {
hi = mid;
}
}
const candidate = new Date(hi);
return compareHijri(toHijri(candidate), target) === 0 ? candidate : null;
}
/**
* إضافة سنوات هجرية كاملة إلى تاريخ ميلادي مع الحفاظ على اليوم الهجري.
* يُخفَّض اليوم 30 إلى 29 عندما يكون الشهر الهدف قصيرًا.
*/
export function addHijriYears(date: Date, years: number): Date {
const current = toHijri(date);
const wanted: HijriParts = {
year: current.year + years,
month: current.month,
day: current.day,
};
const exact = fromHijri(wanted);
if (exact) return exact;
// اليوم 30 غير موجود في كل شهر هجري — نرجع إلى 29.
const clamped = fromHijri({ ...wanted, day: 29 });
if (!clamped) {
throw new Error(
`تعذّر تحديد التاريخ الهجري ${wanted.year}-${wanted.month}-${wanted.day}`,
);
}
return clamped;
}
/** صيغة ISO YYYY-MM-DD، وهي ما تخزّنه وتفهرس عليه. */
export function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}يعمل البحث الثنائي في نحو تسع دورات من استدعاء Intl رخيص. وهو سريع بما يكفي لتشغيله لكل رخصة كل ليلة، وصحيح بحكم بنيته، لأنه لا يمكن أن يعيد إلا تاريخًا توافق ICU نفسها على أنه يقابل الهدف.
لماذا يهم هذا عمليًا
شغّل المحرك على رخصة صادرة اليوم:
const issued = new Date('2026-08-12T00:00:00Z');
console.log(toHijri(issued));
// { year: 1448, month: 2, day: 29 } ← 29 صفر 1448هـ
console.log(toIsoDate(addHijriYears(issued, 1)));
// 2027-08-02 — وليس 2027-08-12
console.log(toIsoDate(addHijriYears(issued, 5)));
// 2031-06-19 — وليس 2031-08-12السنة الهجرية الواحدة تقع قبل الذكرى الميلادية الساذجة بعشرة أيام. وخمس سنوات هجرية تقع قبلها بأربعة وخمسين يومًا. الجدول الذي يضيف سنة إلى التاريخ الميلادي يجعل رخصة عمرها خمس سنوات منتهية منذ ما يقارب شهرين قبل أن ينتبه أحد.
الخطوة 4: توحيد الرخص عند الإدخال
أيًا كان المصدر — نموذج، أو ملف CSV من مكتب خدمات، أو ملف PDF محلَّل — يمر كل شيء عبر موحِّد واحد يملأ التقويم الذي لم تزوّده به.
أنشئ src/ingest.ts:
import { z } from 'zod';
import { addHijriYears, fromHijri, toHijri, toIsoDate } from './hijri';
import { HijriDate, Licence, LicenceKind, LicenceNumber } from './domain';
/** ما يعطينا إياه موظف العمليات أو ملف الاستيراد فعليًا. */
export const LicenceInput = z
.object({
kind: LicenceKind,
licenceNumber: LicenceNumber,
commercialRegistration: z.string(),
branchName: z.string().min(1),
municipality: z.string().min(1),
ownerEmail: z.string().email(),
expiresOnHijri: HijriDate.optional(),
expiresOnGregorian: z.string().date().optional(),
})
.refine(
(input) => input.expiresOnHijri || input.expiresOnGregorian,
'يجب تزويد تاريخ انتهاء واحد على الأقل',
);
export type LicenceInput = z.infer<typeof LicenceInput>;
export function normaliseLicence(
raw: unknown,
id: string,
): Omit<Licence, 'lastVerifiedAt'> & { lastVerifiedAt: null } {
const input = LicenceInput.parse(raw);
let hijri = input.expiresOnHijri;
let gregorian = input.expiresOnGregorian;
// التقويم الذي زوّدنا به المستخدم هو التقويم النظامي للرخصة.
const authoritativeCalendar = input.expiresOnHijri ? 'hijri' : 'gregorian';
if (hijri && !gregorian) {
const resolved = fromHijri(hijri);
if (!resolved) {
throw new Error(
`التاريخ الهجري ${hijri.year}-${hijri.month}-${hijri.day} غير موجود في أم القرى`,
);
}
gregorian = toIsoDate(resolved);
}
if (gregorian && !hijri) {
hijri = toHijri(new Date(`${gregorian}T00:00:00Z`));
}
return {
id,
kind: input.kind,
licenceNumber: input.licenceNumber,
commercialRegistration: input.commercialRegistration,
branchName: input.branchName,
municipality: input.municipality,
ownerEmail: input.ownerEmail,
authoritativeCalendar,
expiresOnHijri: hijri!,
expiresOnGregorian: gregorian!,
lastVerifiedAt: null,
};
}
/** إسقاط موعد التجديد التالي مع احترام التقويم المرجعي. */
export function nextExpiry(licence: Licence, terms = 1): string {
const current = new Date(`${licence.expiresOnGregorian}T00:00:00Z`);
if (licence.authoritativeCalendar === 'hijri') {
return toIsoDate(addHijriYears(current, terms));
}
const projected = new Date(current);
projected.setUTCFullYear(projected.getUTCFullYear() + terms);
return toIsoDate(projected);
}لاحظ أن normaliseLicence يرفض التاريخ الهجري غير الموجود بدل تقريبه بصمت. الرخصة المسجّلة بانتهاء يوم 30 ذي القعدة في سنة يكون فيها الشهر 29 يومًا هي خطأ نسخ، وتريد أن تعرف به وقت الاستيراد لا بعد أحد عشر شهرًا.
الخطوة 5: آلة حالات التصعيد
«الأيام المتبقية» بيانات. أما المالك والخطورة والإجراء التالي فهي نظام. أنشئ src/escalation.ts:
import type { Licence } from './domain';
export type Severity = 'none' | 'low' | 'medium' | 'high' | 'critical';
export interface Stage {
id: string;
severity: Severity;
/** الحد الأعلى شاملًا، بالأيام المتبقية. */
withinDays: number;
action: string;
}
/**
* مرتبة من الأضيق. تجديدات بلدي تحتاج واقعيًا ثلاثة إلى أربعة أسابيع
* عندما يتطلب الأمر إعادة معاينة السلامة، لذا تبدأ حالة 'urgent'
* قبل الموعد بوقت كافٍ لا عنده.
*/
export const STAGES: Stage[] = [
{ id: 'grace', severity: 'critical', withinDays: 7, action: 'تصعيد إلى مدير العمليات اليوم' },
{ id: 'urgent', severity: 'high', withinDays: 30, action: 'قدّم التجديد الآن واحجز معاينة السلامة' },
{ id: 'due', severity: 'medium', withinDays: 60, action: 'تأكد من سريان عقد الإيجار والسجل التجاري' },
{ id: 'upcoming', severity: 'low', withinDays: 90, action: 'أضفها إلى دفعة التجديد القادمة' },
];
export interface Assessment {
licenceId: string;
branchName: string;
daysRemaining: number;
stage: string;
severity: Severity;
action: string;
ownerEmail: string;
}
const DAY_MS = 86_400_000;
export function daysRemaining(licence: Licence, now: Date): number {
const expiry = Date.parse(`${licence.expiresOnGregorian}T00:00:00Z`);
const today = Date.parse(`${now.toISOString().slice(0, 10)}T00:00:00Z`);
return Math.round((expiry - today) / DAY_MS);
}
export function assess(licence: Licence, now: Date): Assessment {
const remaining = daysRemaining(licence, now);
const base = {
licenceId: licence.id,
branchName: licence.branchName,
daysRemaining: remaining,
ownerEmail: licence.ownerEmail,
};
if (remaining < 0) {
return {
...base,
stage: 'expired',
severity: 'critical',
action: 'مزاولة نشاط برخصة منتهية — مراجعة عاجلة لوقف الخسارة',
};
}
const stage = STAGES.find((s) => remaining <= s.withinDays);
return stage
? { ...base, stage: stage.id, severity: stage.severity, action: stage.action }
: { ...base, stage: 'ok', severity: 'none', action: 'لا إجراء' };
}الحالة الحدّية التي تستحق التعمّد: القيمة remaining === 0 تعني أن الرخصة تنتهي اليوم، وهي حالة grace حرجة لا expired. والخطأ بمقدار واحد هنا هو الفرق بين تنبيه يصل وتنبيه يصل متأخرًا يومًا.
الخطوة 6: المطابقة مع الواقع
سجلّك اعتقاد، والاعتقادات تتقادم: يجدّد مدير فرع عبر مكتب خدمات ولا يخبر أحدًا، أو تُلغى رخصة عند إغلاق فرع. وبلا مطابقة، يبلّغك النظام بثقة أن رخصة لم تعد موجودة في وضع سليم.
ولأنه لا توجد واجهة برمجية تستطلعها، فالمطابقة هنا تعني إظهار التقادم كإشارة من الدرجة الأولى وجعل خطوة التحقق البشري رخيصة.
أنشئ src/reconcile.ts:
import type { Licence } from './domain';
import { assess, type Assessment } from './escalation';
const DAY_MS = 86_400_000;
export interface ReconciliationFlag {
licenceId: string;
branchName: string;
reason: 'never_verified' | 'stale_verification' | 'expired_unverified';
detail: string;
}
/**
* عمر النصف للتحقق. الرخصة التي تم التحقق منها قبل 120 يومًا ليست دليلًا،
* بل ذكرى. شدّد هذه القيمة للفروع عالية المخاطر.
*/
const STALE_AFTER_DAYS = 120;
export function reconcile(licences: Licence[], now: Date): {
assessments: Assessment[];
flags: ReconciliationFlag[];
} {
const assessments = licences.map((l) => assess(l, now));
const flags: ReconciliationFlag[] = [];
for (const licence of licences) {
const verdict = assessments.find((a) => a.licenceId === licence.id)!;
if (!licence.lastVerifiedAt) {
flags.push({
licenceId: licence.id,
branchName: licence.branchName,
reason: 'never_verified',
detail: 'أُدخلت ولم يتم تأكيدها قط مقابل منصة بلدي',
});
continue;
}
const ageDays = Math.floor(
(now.getTime() - Date.parse(licence.lastVerifiedAt)) / DAY_MS,
);
if (verdict.stage === 'expired') {
flags.push({
licenceId: licence.id,
branchName: licence.branchName,
reason: 'expired_unverified',
detail: `مسجّلة كمنتهية منذ ${Math.abs(verdict.daysRemaining)} يومًا — تأكد أنها لم تُجدَّد خارج النظام`,
});
} else if (ageDays > STALE_AFTER_DAYS) {
flags.push({
licenceId: licence.id,
branchName: licence.branchName,
reason: 'stale_verification',
detail: `آخر تحقق قبل ${ageDays} يومًا`,
});
}
}
return { assessments, flags };
}العلامة expired_unverified هي التي تثبت جدواها. الرخصة المنتهية في سجلّك تكون في الغالب تجديدًا تم من دون علمك أكثر منها مخالفة فعلية — ومعاملة كل واحدة منها كإنذار حريق تدرّب الناس على تجاهل الإنذار.
اقرن هذا بزر «تحقق» في لوحة التحكم ينقل موظف العمليات مباشرة إلى خدمة الاستعلام في بلدي لرقم تلك الرخصة، ثم يختم lastVerifiedAt عند التأكيد. هذا هو التكامل كله: ما دمت لا تستطيع أتمتة القراءة، اجعل القراءة اليدوية تستغرق خمس عشرة ثانية وسجّل أنها حدثت.
الخطوة 7: ربط المهمة الليلية
// src/job.ts
import { reconcile } from './reconcile';
import type { Licence } from './domain';
interface Notifier {
send(to: string, subject: string, body: string): Promise<void>;
}
export async function runDailyComplianceJob(
licences: Licence[],
notify: Notifier,
now = new Date(),
): Promise<void> {
const { assessments, flags } = reconcile(licences, now);
const actionable = assessments.filter((a) => a.severity !== 'none');
// التجميع حسب المالك حتى لا يتلقى أحدهم أربعين رسالة منفصلة.
const byOwner = new Map<string, typeof actionable>();
for (const item of actionable) {
const bucket = byOwner.get(item.ownerEmail) ?? [];
bucket.push(item);
byOwner.set(item.ownerEmail, bucket);
}
for (const [owner, items] of byOwner) {
const critical = items.filter((i) => i.severity === 'critical').length;
const subject = critical
? `[عاجل] ${critical} رخصة بلدي تحتاج إجراءً اليوم`
: `${items.length} رخصة بلدي تقترب من التجديد`;
const body = items
.sort((a, b) => a.daysRemaining - b.daysRemaining)
.map((i) => `${i.branchName}: ${i.daysRemaining} يومًا — ${i.action}`)
.join('\n');
await notify.send(owner, subject, body);
}
if (flags.length > 0) {
console.warn(`[balady] رُفعت ${flags.length} علامة مطابقة`);
}
}جدولها مرة واحدة يوميًا في وقت مبكر بتوقيت Asia/Riyadh. لا تشغّلها كل ساعة — عمل التجديد يقاس بالأيام، والبريد الساعي هو الطريقة التي تنتهي بها التنبيهات في مجلد لا يفتحه أحد.
اختبار التنفيذ
يستحق محرك التقويم اختبارات حقيقية، لأنه الجزء الذي يبقى فيه الخطأ الدقيق غير مرئي لسنة كاملة. أنشئ src/hijri.test.ts:
import { describe, expect, it } from 'vitest';
import { addHijriYears, fromHijri, toHijri, toIsoDate } from './hijri';
describe('محرك أم القرى', () => {
it('يحوّل تاريخًا معروفًا', () => {
expect(toHijri(new Date('2026-08-12T00:00:00Z'))).toEqual({
year: 1448,
month: 2,
day: 29,
});
});
it('يعود ذهابًا وإيابًا كل أسبوع على مدى إحدى عشرة سنة', () => {
const start = Date.UTC(2018, 0, 1);
for (let i = 0; i < 4000; i += 7) {
const gregorian = new Date(start + i * 86_400_000);
const hijri = toHijri(gregorian);
const back = fromHijri(hijri);
expect(back, `لا تحويل عكسي لـ ${JSON.stringify(hijri)}`).not.toBeNull();
expect(toHijri(back!)).toEqual(hijri);
}
});
it('يتأخر عشرة أيام عن الذكرى الميلادية', () => {
const issued = new Date('2026-08-12T00:00:00Z');
expect(toIsoDate(addHijriYears(issued, 1))).toBe('2027-08-02');
});
it('يراكم الانحراف على مدى خمس سنوات', () => {
const issued = new Date('2026-08-12T00:00:00Z');
expect(toIsoDate(addHijriYears(issued, 5))).toBe('2031-06-19');
});
it('يعيد null ليوم غير موجود', () => {
// نمسح سنة كاملة بحثًا عن الشهور القصيرة ونتأكد أننا لا نخترع تاريخًا.
for (let month = 1; month <= 12; month++) {
const resolved = fromHijri({ year: 1448, month, day: 30 });
if (resolved) expect(toHijri(resolved).day).toBe(30);
}
});
});شغّلها بالأمر npx vitest run. واختبار الذهاب والإياب هو الأهم: فهو يتحقق من البحث الثنائي مقابل ICU على 572 تاريخًا، وهذا دليل أقوى بكثير من حفنة تواريخ مختارة يدويًا.
أما آلة التصعيد فاختبر حدودها صراحة — اليوم 0 و7 و8 و30 و31 وقيمة سالبة — لأن كل واحدة منها موضع يمكن أن تُكتب فيه المتباينة بالاتجاه الخاطئ.
حل المشكلات
الدالة toHijri تعيد سنة خاطئة على نسخة Node صغيرة. أنت على بناء small-icu بلا بيانات المحليات الكاملة. تحقق بالأمر node -p "process.config.variables.icu_small"، وثبّت full-icu أو استخدم توزيعة Node الرسمية.
التواريخ تنزاح يومًا حسب وقت تشغيل المهمة. أنت تقارن طابعًا زمنيًا بتاريخ مدني. وحّد الطرفين دائمًا إلى منتصف ليل UTC على سلسلة ISO كما تفعل daysRemaining أعلاه، واضبط timeZone في المنسّق على Asia/Riyadh حتى لا تقرأ مهمة تعمل الساعة 23:00 UTC تاريخ الأمس الهجري.
الدالة fromHijri تعيد null لتاريخ مطبوع على رخصة حقيقية. سببان محتملان: الرخصة تستخدم صيغة هجرية غير أم القرى (نادر في المستندات الرسمية، شائع في المكتوبة يدويًا)، أو أن التاريخ نُسخ خطأ. أظهر الحالة للمستخدم بدل تقريبها بصمت.
أرقام الرخص ترفض التحقق. الصيغ تتفاوت بين الأمانات، والتصاريح القديمة تسبق الترقيم الحالي. وسّع النمط إلى مدى طولي وسجّل المرفوضات للمراجعة بدل حجب الاستيراد — فالمدقّق الصارم الذي يمنع موظفًا من تسجيل رخصة حقيقية أسوأ من المتساهل.
الخطوات التالية
- طابق السجل التجاري لكل رخصة عبر واجهة واثق حتى يُبطل السجل الملغى رخص الفرع كلها تلقائيًا — راجع التحقق من المنشآت عبر معروف وواثق.
- وسّع آلة التصعيد نفسها لتشمل التزامات قوى ونطاقات، فهي تتبع النمط ذاته: تملّكها أو ادفع الغرامة — تكامل قوى مع أنظمة الموارد البشرية.
- أضف مخرجات النظام إلى طبقة التقارير فوق نظام تخطيط الموارد لديك، ليظهر خطر الرخص بجانب إيراد كل فرع بدل أداة منفصلة.
- إن كنت تشغّل الفوترة الإلكترونية زاتكا المرحلة الثانية، فأعد استخدام مجدول انتهاء الشهادات فيها — الشكل واحد.
الخلاصة
الجزء الصعب في امتثال بلدي لم يكن الواجهة البرمجية يومًا، لأنه لا توجد واجهة برمجية. الصعب أن الموعد النهائي يعيش في تقويم لا تستخدمه منظومتك التقنية، موزعًا على فروع بلا مالك، في سجل لا يتحقق منه أحد.
وقد بنيت الأجزاء الثلاثة التي تعالج ذلك: محرك أم القرى صحيح بحكم بنيته ومُثبت باختبارات ذهاب وإياب، وآلة تصعيد تسنِد كل انتهاء وشيك إلى شخص ومعه إجراء، وحلقة مطابقة تعامل سجلّك ذاته كادعاء لا كحقيقة.
هذه التركيبة تساوي أكثر مما تساويه واجهة برمجية. الواجهة كانت ستخبرك بتاريخ الانتهاء، لكنها لن تخبرك من سيجدّد الرخصة.
بناء طبقة التقارير والامتثال فوق المنصات الحكومية السعودية هو أكثر ما نعمل عليه. إن كنت تجمع مواعيد بلدي وزاتكا وقوى والتأمينات في عرض واحد وتريد رأيًا ثانيًا في البنية قبل أن تلتزم بها، أخبرنا بما تتكامل معه وسنراجعه معك.