تطبيق متجر أو backend يرسل طرودًا في الجزائر عنده نفس المهام القليلة مع شركة التوصيل: إنشاء الطرود، حساب رسوم التوصيل إلى ولاية، الحصول على ملصق الطرد، ومتابعة كل طرد حتى يُحصَّل مبلغ الدفع عند الاستلام (COD). ولكل شركة توصيل API خاص بها، وأسماء حقول خاصة، وقائمة حالات خاصة.
فيُكتب كود الربط مرة لكل شركة، ويتسرّب كل واحد إلى بقية التطبيق: شكل JSON مختلف للطرد، وطريقة مختلفة لقول "خرج للتوصيل"، وخطأ مختلف عندما يكون رقم الهاتف خاطئًا.
dz_delivery حزمة (package) بلغة Dart تضع هذه الـ APIs خلف واجهة واحدة بأنواع محددة (typed) اسمها DeliveryProvider، تُستعمل من تطبيق Flutter أو من backend بلغة Dart.
وهناك تنبيه يضعه الـ README في أعلاه: الحزمة لم تُجرَّب على الـ API الحي. كل عميل فيها بُني من توثيق الـ API المنشور لدى الشركة، وليس من حركة حقيقية، لأنه لم تتوفر مفاتيح API. وإن لم يتصرف نداء كما هو موصوف، يطلب الـ README التبليغ عن الفرق في issue.
ماذا تقدّم
واجهة واحدة. DeliveryProvider فيها createParcels (على دفعات حيث تسمح الشركة)، وgetParcel، وlistParcels مع التصفية والتقسيم إلى صفحات، وtrackingHistory، وdeleteParcel، وcancelParcel، وdeliveryFees، وwilayas، وcommunes، وstopDesks، وlabelUrl. والكائن capabilities يخبرك بما تدعمه كل شركة، وكل ما هو غير مدعوم يرمي DeliveryUnsupportedException.
نماذج محايدة. ParcelRequest يحمل المستلم، والهواتف، والعنوان، ورمز الولاية، والبلدية، ووصف المنتج، ومبلغ COD بالدينار دون سنتيمات، والقيمة المصرَّح بها، والتسليم في المكتب (stop desk)، والتوصيل المجاني، والاستبدال، والوزن، والأبعاد، ورقم الطلب، والملاحظات. وفي الاتجاه الآخر تحصل على Parcel وTrackingEvent وDeliveryFee وWilaya وCommune وStopDesk وParcelBatchResult. الدفعة قد تنجح جزئيًا، ولهذا تسرد النتيجة الطرود المُنشأة والطرود المرفوضة معًا.
enum واحد للحالة. ParcelStatus هو نفسه لكل الشركات. كل شركة تحوّل نصوص الحالة الخام المذكورة في توثيقها. النص الخام يُحفظ في rawStatus، وكل ما هو غير موثَّق يتحوّل إلى ParcelStatus.unknown.
أخطاء محددة الأنواع. DeliveryAuthException (401/403)، وDeliveryValidationException (مع fieldErrors)، وDeliveryRateLimitException (مع retryAfter من ترويسة Retry-After)، وDeliveryNotFoundException، وDeliveryServerException، وDeliveryNetworkException، وDeliveryHttpException. كلها ترث من الصنف المغلق (sealed) DeliveryException.
فحوص قبل أي طلب. أرقام الهاتف الجزائرية (0550… و+213… و00213… والأرقام الهندية-العربية)، ورموز الولايات من 1 إلى 69، والحقول الإلزامية. والدفعات غير الصالحة تُرفض قبل إرسال أي شيء.
تحديد المعدل (rate limiting). RateLimiter اختياري بنوافذ منزلقة للثانية والدقيقة والساعة واليوم، تتشاركه كل طلبات الشركة الواحدة. ويتوقف أيضًا عندما يرد الخادم بـ 429 مع Retry-After، والخيار maxRetries يعيد محاولة ردود 429.
HTTP قابل للحقن وعملاء خام. يمكنك تمرير أي Client من package:http، مثل MockClient في الاختبارات. وكل شركة تعرض أيضًا عميلها الخاص، الذي يُرجع الـ JSON المفكوك للحقول التي لا يغطيها النموذج المشترك، مع منفذ send() لأي شيء آخر.
أي شركات
اليوم، واحدة: ZR Express، عبر ZrExpressProvider. يستهدف ZR Express Suppliers Services API على https://api.zrexpress.app/api/v1/، ويستوثق بالترويستين X-Api-Key وX-Tenant. بُني من التوثيق المنشور على docs.zrexpress.app كما قُرئ يوم 2026-09-25.
قاعدة الحزمة هي تنفيذ ما توثّقه الشركة علنًا فقط. ولهذا تُذكر البقية على أنها غير مضمَّنة، مع سبب كل واحدة:
- Yalidine وGuepex. توثيق الـ API موجود خلف تسجيل دخول التاجر. توجد إعادة بناء من أطراف ثالثة، لكنها ليست توثيق الشركة، فلم يؤخذ منها أي endpoint.
- شركات Ecotrack (DHD وConexlog وWorld Express وغيرها). لم يُعثر على توثيق API منشور. الوصول إليه يتم من لوحة تحكم كل شركة.
- API القديم لـ ZR Express (Procolis). صفحة المطوّرين لم تكن متاحة وليس لها نسخة مؤرشفة. والـ API الحالي لـ ZR Express يعوّضه.
- Maystro Delivery. الوصول إلى الـ API يتم بطلب. لا يوجد مرجع منشور.
- Noest. لم يُعثر على مرجع API منشور.
إن كان عندك رابط عام لأي من هذه التوثيقات، يطلب الـ README فتح issue. وإضافة شركة تعني تنفيذ DeliveryProvider.
طريقة الاستعمال
أضِفها من GitHub:
dependencies: dz_delivery: git: https://github.com/AbdeldjalilChougui/dz_delivery ثم، من الـ README:
import 'package:dz_delivery/dz_delivery.dart'; Future<void> main() async { final DeliveryProvider zr = ZrExpressProvider( apiKey: 'YOUR-API-KEY', tenantId: 'YOUR-TENANT-ID', rateLimiter: RateLimiter.windows(perSecond: 5), // optional maxRetries: 2, // retry 429 responses after Retry-After ); try { final fee = await zr.deliveryFees(16, 31, stopDesk: true); print('Stop desk in Oran: ${fee.amount} DZD'); final result = await zr.createParcels([ const ParcelRequest( recipientName: 'Amine Bouzid', phones: ['0551 23 45 67'], address: '12 rue Larbi Ben Mhidi', wilayaCode: 31, commune: 'Oran', productDescription: 'Sneakers 42', codAmount: 4500, // ZR Express: total collected, delivery fee included orderId: 'ORD-1001', ), ]); for (final f in result.failed) { print('#${f.index} rejected: ${f.code} ${f.message}'); } for (final c in result.created) { final parcel = await zr.getParcel(c.tracking); print('${parcel.tracking}: ${parcel.status} (${parcel.rawStatus})'); } } on DeliveryValidationException catch (e) { print(e.fieldErrors.join('\n')); } on DeliveryRateLimitException catch (e) { print('Retry after ${e.retryAfter}'); } on DeliveryException catch (e) { print(e); } finally { zr.close(); } } وللحقول التي لا يغطيها النموذج المشترك، استعمل العميل الخام:
final raw = await zr.api.getParcel('16-IB3HI9-ZR'); print(raw['situation']); // ZR Express "situation" (sub-status) final profile = await zr.api.send('GET', 'users/profile'); وفي الاختبارات مرّر httpClient: MockClient(...) إلى الشركة، فلا يخرج أي طلب من جهازك.
تفاصيل ZR Express التي تستحق القراءة
الـ README يقابل كل حقل في ParcelRequest بحقله في ZR Express، وكل اسم حالة موثَّق بقيمة من ParcelStatus (الحالة commande_recue تصير created، وvers_wilaya تصير inTransit، وlivre تصير delivered، وencaisse تصير codCollected، وهكذا). وبعض الملاحظات من توثيق الشركة تؤثر على كودك:
- الطرود المُنشأة دون
stateIdتبدأ بحالة "OrderReceived" ويجب نقلها إلى "ReadyToDispatch" قبل أن يقبلها المركز (hub). مرّرinitialStateIdلإنشائها جاهزة للإرسال. -
createParcelsتستعمل endpoint الدفعات، بحد أقصى 100 طرد في الطلب. والعميل يقسّم الدفعات الأكبر تلقائيًا. -
cancelParcelغير مدعومة في ZR Express. احذف الطرد قبل الاستلام بدل ذلك. -
codAmountيُرسل في الحقلamount، والتوثيق يصفه بأنه لا يتجاوز 150,000 دينار مع رسوم التوصيل. أماfreeShippingوdeclaredValueوnotesفليس لها حقل ولا تُرسل. وexchange: trueيُرفض، لأن الاستبدال عملية مستقلة في ZR Express. - أسعار التوصيل تتعلق بالوجهة فقط، ولهذا تتجاهل
deliveryFeesالقيمةfromWilaya. - الـ endpoint الخاص بسجل الحالات محدود المعدل، والتوثيق ينصح بالـ webhooks بدل الاستعلام المتكرر (polling).
ما يجب معرفته
- لا شيء هنا جُرّب على خادم حي. أشكال الطلبات والردود تتبع التوثيق المنشور بتاريخ 2026-09-25. والـ README يذكر أيضًا مواضع يتناقض فيها هذا التوثيق مع نفسه، ويقول أي نسخة يتبعها العميل.
- ZR Express فقط هي المضمَّنة.
- غير مغلَّف في ZR Express: الاستبدال، والاسترجاع المالي، وأكياس الاستلام، والمنتجات، والشكاوى، والـ webhooks، والخزينة. الوصول إليها عبر
api.send. - تحويل الحالات يغطي فقط الأسماء الظاهرة في التوثيق. مسارات العمل في ZR Express قابلة للضبط لكل tenant، فالأسماء الأخرى ترجع
ParcelStatus.unknownمع ملءrawStatus. - المبالغ تُحوَّل إلى دنانير صحيحة بـ
round(). - التحقق من الهاتف يفحص الشكل فقط (جوّال يبدأ بـ 5 أو 6 أو 7 ثم 8 أرقام، وثابت يبدأ بـ 2 أو 3 أو 4 ثم 7 أرقام). لا يفحص توزيع الأرقام على المتعاملين.
- رموز الولايات من 1 إلى 69 مقبولة. أما هل تخدم الشركة رمزًا معيّنًا فذلك يعود إليها.
- بيانات الاختبار. الـ fixtures أمثلة ردود منسوخة حرفيًا من توثيق ZR Express، وكل واحد يذكر صفحته، مع بضعة ردود مركَّبة معلَّمة على أنها مركَّبة.
لماذا بنيتها
المشكلة هي ما في السطور الأولى من الـ README: إنشاء الطرود وحساب الرسوم وتتبّع الشحنات ينبغي أن يكون له نفس الشكل في كودك مهما كانت شركة التوصيل التي تحمل الطرد. ParcelRequest واحد، وParcelStatus واحد، وعائلة واحدة من الاستثناءات، والفروق بين الشركات تبقى داخل صنف الشركة.
وأردت أيضًا أن تكون الحزمة صريحة في ما تعرفه. تنفّذ فقط ما توثّقه الشركة علنًا، وتذكر تاريخ قراءة التوثيق، وتسرد الشركات التي لم تستطع تضمينها والسبب، بدل تخمين endpoints من كود أطراف ثالثة.
الكود
الكود، وجداول مقابلة الـ endpoints والحقول، والاختبارات موجودة على GitHub: https://github.com/AbdeldjalilChougui/dz_delivery. الرخصة MIT. وإن كانت عندك مفاتيح API ووجدت شيئًا لا يطابق، فإن issue يصف الفرق هو أنفع مساهمة.
نُشر في الأصل على abdeldjalilchougui.biz.