DEV Community

kouana
kouana

Posted on

تطبيق طلب سيارات: خمسة قرارات معمارية تُحسم قبل أول سطر كود

بنينا منصة نقل ركاب كاملة: تطبيق للراكب، وتطبيق للسائق، ولوحة تحكم، وواجهة برمجية من أربعين مسارًا. الخلفية على Laravel مع لوحة Filament، والتطبيقان بـFlutter، والاتصال اللحظي عبر Firebase. ستة عشر نموذج بيانات وعشرون ترحيلًا وتسع شاشات إدارة.

الجزء الصعب لم يكن أي تقنية بعينها. الصعوبة أن ثلاثة أنظمة تعمل في اللحظة نفسها: راكب ينتظر، وسائق يقرر خلال ثوانٍ، ولوحة تراقب وتسعّر وتوثّق. تأخير ثانيتين في وصول عرض الرحلة يعني رحلة ضائعة، لا تجربة أبطأ.

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

1. مصدران للحقيقة، لكل منهما وظيفة مختلفة

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

في المقابل، الأجرة وحالة الرحلة لا تحتمل قاعدة بلا معاملات. مبلغ يُحتسب مرتين أو حالة تنتقل من اتجاهين في وقت واحد يعني نزاعًا ماليًا.

فصلنا الاثنين حسب طبيعتهما:

البيانات المكان السبب
موقع السائق، حالة الاتصال، إشعار العرض Firebase تحديث كثيف قصير العمر، يصل فورًا
الرحلة، الأجرة وبنودها، السائق، الوثائق Laravel + قاعدة علاقية معاملات، قيود، تدقيق

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

2. الأجرة تُخزَّن بنودًا لا رقمًا واحدًا

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

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

// الشكل العام: بنود منفصلة، والإجمالي مشتق منها لا مخزَّن بدلًا عنها
$fare = [
    'base'     => $tariff->base_fare,
    'distance' => round($km * $tariff->per_km, 2),
    'time'     => round($minutes * $tariff->per_minute, 2),
];
$fare['subtotal'] = array_sum($fare);
$fare['tax']      = round($fare['subtotal'] * $tariff->tax_rate, 2);
$fare['total']    = $fare['subtotal'] + $fare['tax'];
Enter fullscreen mode Exit fullscreen mode

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

3. الرحلة آلة حالات، وكل شيء آخر يتفرع منها

أربعون مسارًا في الواجهة البرمجية تبدو كثيرة حتى ترسم دورة حياة الرحلة، فتجد أغلبها انتقالات مشروعة بين حالتين.

requested → offered → accepted → arrived → started → completed
     ↘ cancelled_by_rider          ↘ cancelled_by_driver
Enter fullscreen mode Exit fullscreen mode

اجعل الانتقال المسموح بيانات لا شروطًا متناثرة في المتحكمات:

private const ALLOWED = [
    'requested' => ['offered', 'cancelled_by_rider'],
    'offered'   => ['accepted', 'cancelled_by_rider', 'expired'],
    'accepted'  => ['arrived', 'cancelled_by_rider', 'cancelled_by_driver'],
    'arrived'   => ['started', 'cancelled_by_driver'],
    'started'   => ['completed'],
];

public function moveTo(Trip $trip, string $next): void
{
    if (!in_array($next, self::ALLOWED[$trip->status] ?? [], true)) {
        throw new InvalidTransition($trip->status, $next);
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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

سباق القبول: سائقان يضغطان في اللحظة نفسها

العرض يصل إلى أكثر من سائق. اثنان يقبلان بفارق أجزاء من الثانية. الكود الساذج يقرأ ثم يكتب:

$trip = Trip::find($id);
if ($trip->status === 'offered') {      // كلاهما يقرأ 'offered'
    $trip->update(['status' => 'accepted', 'driver_id' => $driverId]);
}
Enter fullscreen mode Exit fullscreen mode

كلا الطلبين يمر، والثاني يدهس الأول. الراكب يرى سائقًا، وسائق آخر يقود إليه بلا رحلة.

اجعل الشرط جزءًا من الكتابة نفسها، واقرأ عدد الصفوف المتأثرة:

$claimed = Trip::where('id', $id)
    ->where('status', 'offered')          // الشرط داخل UPDATE
    ->update(['status' => 'accepted', 'driver_id' => $driverId]);

if ($claimed === 0) {
    return response()->json(['error' => 'trip_already_taken'], 409);
}
Enter fullscreen mode Exit fullscreen mode

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

4. تطبيقان منفصلان، لا تطبيق واحد بوضعين

الإغراء واضح: تطبيق Flutter واحد يفتح واجهة الراكب أو السائق حسب نوع الحساب. المشترك بينهما كبير فعلًا، من الخرائط إلى التحقق إلى الإشعارات.

بنيناهما تطبيقين مستقلين. الأسباب عملية لا نظرية:

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

المنطق المشترك يعيش في حزمة داخلية يستوردها الاثنان:

packages/core/       # النماذج، عميل الواجهة البرمجية، التحقق، الخرائط
apps/rider/
apps/driver/
Enter fullscreen mode Exit fullscreen mode

5. المراجعة البشرية جزء من التصميم لا من سياسة الدعم

شكوى متكررة أخرى في هذا المجال: حظر آلي بلا تفسير ولا مسار مراجعة. عالجناها في البنية: وثائق السائق ورخصته وتأمينه تُرفع، وتُراجَع من لوحة التحكم، والقرار يُسجَّل بصاحبه وسببه ووقته.

الفرق أن حالة السائق ليست علمًا منطقيًا is_active، بل سجل قرارات:

Schema::create('driver_reviews', function (Blueprint $t) {
    $t->foreignId('driver_id')->constrained();
    $t->foreignId('reviewer_id')->constrained('users');
    $t->enum('decision', ['approved', 'rejected', 'suspended']);
    $t->string('reason');
    $t->timestamps();
});
Enter fullscreen mode Exit fullscreen mode

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

مصيدتان صغيرتان كلّفتانا وقتًا

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

المسافة المدفوعة ليست خطًا مستقيمًا بين نقطتين. خزّن مسار الرحلة نقاطًا واحسب الطول التراكمي. الفارق بين الخط المستقيم والمسار الفعلي في مدينة مزدحمة كبير بما يكفي لتحويل كل رحلة إلى شكوى.

الخلاصة

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

نعمل في رؤية على تصميم تطبيقات الجوال وأنظمتها الخلفية، وهذا المشروع كان أوضح مثال على أن الوقت المدفوع في نمذجة البيانات يُوفَّر مضاعفًا في الشهر الثالث.

Top comments (0)