diff --git a/backend/bot/cron_driver_score.php b/backend/bot/cron_driver_score.php new file mode 100644 index 00000000..80af86a8 --- /dev/null +++ b/backend/bot/cron_driver_score.php @@ -0,0 +1,145 @@ +getMessage() . "\n"); + exit(1); +} + +// ═══════════════════════════════════════════════════════════════ +// ١) التقييمات — استعلام واحد مجمّع لكل السائقين +// ‏نافذة ٩٠ يوماً: تقييم قبل سنة لا يصف السائق اليوم. +// ═══════════════════════════════════════════════════════════════ +$ratings = []; +try { + $stmt = $con->query(" + SELECT driver_id, AVG(rating) AS avg_rating, COUNT(*) AS cnt + FROM ratingDriver + WHERE driver_id IS NOT NULL + AND rating IS NOT NULL + AND created_at >= NOW() - INTERVAL 90 DAY + GROUP BY driver_id + "); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + // ‏تقييم واحد أو اثنان لا يكفيان لحكم — ننعّمه نحو المحايد (٣٫٥/٥) + $n = (int) $row['cnt']; + $avg = (float) $row['avg_rating']; + $ratings[$row['driver_id']] = round((($avg * $n) + (3.5 * 3)) / ($n + 3), 3); + } +} catch (PDOException $e) { + fwrite(STDERR, "[driver_score] تعذّر جلب التقييمات: " . $e->getMessage() . "\n"); +} + +// ═══════════════════════════════════════════════════════════════ +// ٢) المرور على مفاتيح الدرجات الموجودة +// ‏scan لا keys: keys تحجب Redis بالكامل على قاعدة كبيرة. +// ═══════════════════════════════════════════════════════════════ +$processed = 0; +$iterator = null; + +// ‏بلا هذا الضبط يرجع phpredis false عند دفعة فارغة فتتوقف الحلقة مبكراً +$redisLocation->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY); + +while (($keys = $redisLocation->scan($iterator, 'driver:score:*', 200)) !== false) { + foreach ($keys as $key) { + $driverId = substr($key, strlen('driver:score:')); + if ($driverId === '') continue; + + try { + $row = $redisLocation->hGetAll($key) ?: []; + $offers = (float) ($row['offers'] ?? 0); + $accepts = (float) ($row['accepts'] ?? 0); + + // ‏لا يتجاوز المقبول المعروض — يحدث لو أُعيد ضبط أحدهما وحده. + $accepts = min($accepts, $offers); + + $rate = ($accepts + SCORE_PRIOR_ACCEPTS) / ($offers + SCORE_PRIOR_OFFERS); + + $update = ['accept_rate' => round($rate, 4)]; + + if (isset($ratings[$driverId])) { + $update['rating'] = $ratings[$driverId]; + } + + // ‏الاضمحلال: نافذة متحرّكة بدل سجل العمر كله. + $update['offers'] = round($offers * SCORE_DECAY, 3); + $update['accepts'] = round($accepts * SCORE_DECAY, 3); + + $redisLocation->hMSet($key, $update); + $redisLocation->expire($key, SCORE_KEY_TTL); + $processed++; + } catch (Throwable $e) { + fwrite(STDERR, "[driver_score] خطأ على $key: " . $e->getMessage() . "\n"); + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// ٣) سائقون لهم تقييم ولم يُعرض عليهم شيء بعد +// ‏بلا هذه الخطوة يظل التقييم معطّلاً حتى أول عرض — أي أن السائق +// ‏الممتاز الجديد لا يستفيد من تقييمه في أول ترتيب له. +// ═══════════════════════════════════════════════════════════════ +$seeded = 0; +foreach ($ratings as $driverId => $rating) { + $key = "driver:score:$driverId"; + try { + if (!$redisLocation->exists($key)) { + $redisLocation->hMSet($key, [ + 'rating' => $rating, + 'accept_rate' => round(SCORE_PRIOR_ACCEPTS / SCORE_PRIOR_OFFERS, 4), + ]); + $redisLocation->expire($key, SCORE_KEY_TTL); + $seeded++; + } + } catch (Throwable $e) { + // تجاهُل — المرور القادم يعالجه + } +} + +echo "[driver_score] حُدِّث $processed سائق، وأُنشئ $seeded من التقييمات." + . " تقييمات محمّلة: " . count($ratings) . "\n"; diff --git a/backend/ride/dispatch/dispatch_helper.php b/backend/ride/dispatch/dispatch_helper.php new file mode 100644 index 00000000..6dc8128d --- /dev/null +++ b/backend/ride/dispatch/dispatch_helper.php @@ -0,0 +1,229 @@ += 10.0 ? 0.0 : (1.0 - ($distanceKm / 10.0)); + + // ── ٢. معدّل القبول التاريخي (20%) — من Redis، محايد إن غاب. + $acceptRate = isset($signals['accept_rate']) + ? max(0.0, min(1.0, (float) $signals['accept_rate'])) + : 0.5; + + // ── ٣. التقييم (15%) — من ٥، محايد إن غاب. + $rating = isset($signals['rating']) + ? max(0.0, min(1.0, ((float) $signals['rating']) / 5.0)) + : 0.5; + + // ── ٤. مدة الخمول (15%) — إنصاف: من انتظر أطول يُقدَّم. + // ‏يُشبَع عند ٣٠ دقيقة حتى لا يحتكر سائق نائم كل الرحلات. + $idleSeconds = (int) ($signals['idle_seconds'] ?? 0); + $idle = $idleSeconds <= 0 ? 0.5 : min(1.0, $idleSeconds / 1800.0); + + // ── ٥. ملاءمة الوجهة (10%) — السائق الذي سجّل وجهة توافق مسار الرحلة. + // ‏findBestDrivers يستبعد من وجهته أبعد من ٥كم، فوجود الوجهة هنا يعني + // ‏توافقاً مؤكداً لا مجرد احتمال. + $destinationFit = !empty($driver['has_destination']) ? 1.0 : 0.5; + + return round( + (0.40 * $proximity) + + (0.20 * $acceptRate) + + (0.15 * $rating) + + (0.15 * $idle) + + (0.10 * $destinationFit), + 4 + ); +} + +/** + * يقرأ إشارات الترتيب لكل السائقين المرشحين بنداء Redis واحد (pipeline). + * + * ‏المفاتيح تُغذّى من كرونات قائمة أو تُترك فارغة — الغياب لا يكسر شيئاً، + * ‏بل يجعل المكوّن محايداً. هذا يسمح بإطلاق المحرك قبل بناء مغذّياته. + */ +function dispatchReadSignals($redisLocation, array $driverIds): array +{ + $signals = []; + if (!$redisLocation || empty($driverIds)) { + return $signals; + } + + try { + $pipe = $redisLocation->multi(Redis::PIPELINE); + foreach ($driverIds as $id) { + $pipe->hGetAll("driver:score:$id"); + } + $rows = $pipe->exec() ?: []; + + foreach ($driverIds as $i => $id) { + $row = $rows[$i] ?? []; + if (!is_array($row) || empty($row)) { + continue; + } + $signals[$id] = [ + 'accept_rate' => $row['accept_rate'] ?? null, + 'rating' => $row['rating'] ?? null, + // ‏آخر لحظة أنهى فيها رحلة — نحوّلها لمدة خمول. + 'idle_seconds' => isset($row['last_ride_end']) + ? max(0, time() - (int) $row['last_ride_end']) + : 0, + ]; + } + } catch (Throwable $e) { + error_log("[dispatch] تعذّرت قراءة إشارات الترتيب: " . $e->getMessage()); + } + + return $signals; +} + +/** + * ‏يرتّب المرشحين ويسلّمهم للسوكِت ليمنح الأفضلَ سبقاً حصرياً قصيراً. + * + * @return bool ‏true إن بدأ الإسناد بالدفعات، false إن تعذّر (فيجب أن يقع + * ‏المستدعي على البثّ الحر — لا تُترك رحلة بلا إسناد). + */ +function dispatchStartBatched( + $redisLocation, + $rideId, + array $driversData, + array $marketPayload +): bool { + if (!$redisLocation || empty($driversData)) { + error_log("[dispatch] لا مرشحين أو Redis غير متاح RideID=$rideId" + . " — السقوط للبثّ الحر"); + return false; + } + + $driverIds = array_values(array_filter(array_column($driversData, 'driver_id'))); + $signals = dispatchReadSignals($redisLocation, $driverIds); + + // الترتيب بالدرجة تنازلياً + $ranked = []; + foreach ($driversData as $driver) { + $id = $driver['driver_id'] ?? null; + if (!$id) { + continue; + } + $ranked[] = [ + 'driver_id' => (string) $id, + 'score' => dispatchScoreDriver($driver, $signals[$id] ?? []), + ]; + } + + usort($ranked, fn($a, $b) => $b['score'] <=> $a['score']); + $ranked = array_slice($ranked, 0, DISPATCH_MAX_CANDIDATES); + + if (empty($ranked)) { + return false; + } + + try { + $queueKey = "ride:$rideId:dispatch_queue"; + $redisLocation->del($queueKey); + foreach ($ranked as $r) { + $redisLocation->rPush($queueKey, $r['driver_id']); + } + // ‏عمر قصير: السبق خمس ثوانٍ ثم يُطلق للجميع، فلا معنى لبقاء + // ‏الطابور بعدها. هامش سخي للمهل الشبكية فقط. + $ttl = dispatchHeadStartSeconds() + 30; + $redisLocation->expire($queueKey, $ttl); + + // ‏الحمولة التي سيراها المرشح — بلا وجهة نهائية (انظر أدناه). + $redisLocation->setex( + "ride:$rideId:dispatch_payload", + $ttl, + // ‏الحمولة كاملة بالإحداثيات واسم الوجهة — قرار المالك: السائق + // ‏يحتاجها لرسم البولي‌لاين والخريطة، ومن حقه معرفة وجهته قبل + // ‏القبول (منطقة خطرة، بعيدة، أو لا يريدها). لا إخفاء. + json_encode($marketPayload, JSON_UNESCAPED_UNICODE) + ); + } catch (Throwable $e) { + error_log("[dispatch] تعذّر بناء الطابور RideID=$rideId: " . $e->getMessage()); + return false; + } + + error_log("[dispatch] RideID=$rideId — سبق " . dispatchHeadStartSeconds() + . "ث للسائق " . $ranked[0]['driver_id'] + . " (درجة " . $ranked[0]['score'] . ") ثم إطلاق للجميع"); + + return true; +} diff --git a/backend/ride/rides/acceptRide.php b/backend/ride/rides/acceptRide.php index 1a799855..01e94aff 100644 --- a/backend/ride/rides/acceptRide.php +++ b/backend/ride/rides/acceptRide.php @@ -69,6 +69,32 @@ try { error_log("[accept_ride] تعذّر فحص الإيقاف: " . $eBlock->getMessage()); } + // ═══════════════════════════════════════════════════════════ + // STEP A0b — حارس الحصرية في الإسناد بالدفعات + // + // ‏بدون هذا الفحص تبقى الحصرية شكلية: سائق تلقّى العرض سابقاً (أو صاغ + // ‏الطلب يدوياً) يستطيع القبول خارج دوره فيعود السباق كما كان. + // + // ‏وجود المفتاح هو ما يحدد الوضع: البثّ الحر لا يكتبه أصلاً، فالفحص + // ‏يمرّ بلا أثر — لا حاجة لقراءة DISPATCH_MODE هنا. + // ═══════════════════════════════════════════════════════════ + try { + if (isset($redisLocation) && $redisLocation) { + $currentOffer = $redisLocation->get("ride:$rideId:current_offer"); + if ($currentOffer !== false && $currentOffer !== null + && (string) $currentOffer !== (string) $driverId) { + error_log("[accept_ride] DriverID=$driverId حاول قبول RideID=$rideId" + . " والعرض الحالي لـ $currentOffer — رُفض"); + printFailure("Ride not available"); + exit; + } + } + } catch (Throwable $eOffer) { + // ‏فشل الفحص لا يمنع القبول — القفل التفاؤلي في STEP A يبقى الضامن + // ‏ضد الازدواج، وأسوأ نتيجة هنا هي عودة سلوك البثّ الحر لهذه الرحلة. + error_log("[accept_ride] تعذّر فحص حصرية العرض: " . $eOffer->getMessage()); + } + // ═══════════════════════════════════════════════════════════ // STEP A — القفل على ride DB (المرجع الأساسي) // Optimistic lock: نغير فقط إذا status لا يزال 'waiting' أو 'wait' @@ -93,6 +119,17 @@ try { error_log("[accept_ride] ride DB locked. RideID=$rideId → DriverID=$driverId"); + // ‏عدّاد القبول — بسط معدّل القبول الذي يرتّب به محرك الإسناد. + // ‏يُعدّ هنا بعد نجاح القفل حصراً: العدّ قبله كان سيحسب للخاسرين في + // ‏السباق قبولاً لم يحدث. + try { + if (isset($redisLocation) && $redisLocation) { + $redisLocation->hIncrBy("driver:score:$driverId", 'accepts', 1); + } + } catch (Throwable $eScore) { + error_log("[accept_ride] تعذّر تحديث عدّاد القبول: " . $eScore->getMessage()); + } + // ═══════════════════════════════════════════════════════════ // STEP B — تزامن primary DB (بعد نجاح القفل) // ═══════════════════════════════════════════════════════════ diff --git a/backend/ride/rides/add_ride.php b/backend/ride/rides/add_ride.php index b5b89b4f..f2e4a1f5 100644 --- a/backend/ride/rides/add_ride.php +++ b/backend/ride/rides/add_ride.php @@ -16,22 +16,20 @@ try { // ================================================================================= // 🛠️ دالة مساعدة: إرسال الرحلة لسوق السائقين (Marketplace Broadcast) // ================================================================================= -function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketData = []) { - $url = getenv('LOCATION_SOCKET_URL') ?: 'http://socket_driver:2021'; - if (strpos($url, 'localhost') !== false || strpos($url, '127.0.0.1') !== false) { - if (file_exists('/.dockerenv')) { - $url = str_replace(['localhost', '127.0.0.1'], 'socket_driver', $url); - } - } - $INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : ''; - // ⚠️ هذه الحمولة تُبَثّ لكل سائق قريب (وليس للفائز فقط) — لا نضع فيها أي - // بيانات راكب حسّاسة (هاتف/إيميل/FCM token). ما ينقص السائق من تفاصيل - // الراكب يجلبه بعد نجاح القبول، والسيرفر يحلّ التوكن بنفسه في acceptRide.php. - // - // أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر - // رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح — أي حقل ناقص - // يصل للسيرفر لاحقاً كنص "null". - $marketPayload = array_merge([ +/** + * ‏حمولة الرحلة كما يراها السائق قبل القبول. + * + * ‏مشتركة بين البثّ الحر والإسناد بالدفعات عمداً: أي حقل يظهر في أحدهما + * ‏ولا يظهر في الآخر يصل للسيرفر لاحقاً كنص "null". + * + * ⚠️ ‏لا تضع هنا أي بيانات راكب حسّاسة (هاتف/إيميل/FCM token) — هذه الحمولة + * ‏تصل سائقين لن يقودوا الرحلة. التفاصيل تُجلب بعد نجاح القبول. + * + * ‏أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر + * ‏رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح. + */ +function buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData = []) { + return array_merge([ 'id' => (string)$rideId, 'start_lat' => $lat, 'start_lng' => $lng, @@ -48,6 +46,52 @@ function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketDa 'passengerRate' => $payloadData[33], 'passengerId' => $payloadData[7], ], $extraMarketData); +} + +/** + * ‏يوقظ العرض المتسلسل في السوكِت بعد أن بنى dispatchStartBatched الطابور. + * ‏السوكِت هو صاحب المؤقّت لأنه العملية الوحيدة طويلة العمر (Workerman + * ‏Timer) — كرون كل دقيقة لا يصلح لمهلة عشر ثوانٍ. + */ +function notifySocketStartBatchedDispatch($rideId) { + $url = getenv('LOCATION_SOCKET_URL') ?: 'http://socket_driver:2021'; + if (file_exists('/.dockerenv')) { + $url = str_replace(['localhost', '127.0.0.1'], 'socket_driver', $url); + } + $INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : ''; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ + 'action' => 'start_batched_dispatch', + 'ride_id' => $rideId, + ])); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT_MS, 2000); + if ($INTERNAL_KEY) { + curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-internal-key: $INTERNAL_KEY"]); + } + curl_exec($ch); + curl_close($ch); +} + +function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketData = []) { + $url = getenv('LOCATION_SOCKET_URL') ?: 'http://socket_driver:2021'; + if (strpos($url, 'localhost') !== false || strpos($url, '127.0.0.1') !== false) { + if (file_exists('/.dockerenv')) { + $url = str_replace(['localhost', '127.0.0.1'], 'socket_driver', $url); + } + } + $INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : ''; + // ⚠️ هذه الحمولة تُبَثّ لكل سائق قريب (وليس للفائز فقط) — لا نضع فيها أي + // بيانات راكب حسّاسة (هاتف/إيميل/FCM token). ما ينقص السائق من تفاصيل + // الراكب يجلبه بعد نجاح القبول، والسيرفر يحلّ التوكن بنفسه في acceptRide.php. + // + // أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر + // رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح — أي حقل ناقص + // يصل للسيرفر لاحقاً كنص "null". + $marketPayload = buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData); $postData = [ 'action' => 'market_new_ride', @@ -428,15 +472,44 @@ try { } } - if (!empty($driversData)) { - dispatchRideToDrivers($driversData, $insertedId, $payload, $start_name_loc, $encryptionHelper, $extraDispatchData); - error_log("[add_ride] Dispatched RideID=$insertedId to " . count($driversData) . " drivers."); - } else { - error_log("[add_ride] No direct drivers found for RideID=$insertedId — market only."); + // ═══════════════════════════════════════════════════════════ + // الإسناد — بالدفعات أو بالبثّ الحر + // + // ‏batched: عرض حصري متسلسل على مرشحين مرتبين. لا بثّ للسوق، ولا + // ‏إشعار جماعي — وإلا لضاع معنى الحصرية. + // ‏broadcast (الافتراضي): السلوك القائم حرفياً بلا أي تغيير. + // ═══════════════════════════════════════════════════════════ + require_once __DIR__ . '/../dispatch/dispatch_helper.php'; + + $batchedStarted = false; + if (dispatchIsBatched((string) ($payload[31] ?? ''))) { + $batchedStarted = dispatchStartBatched( + $redisLocation ?? null, + $insertedId, + $driversData ?: [], + buildMarketPayload($insertedId, $startLat, $startLng, $payload, $extraDispatchData) + ); + + if ($batchedStarted) { + notifySocketStartBatchedDispatch($insertedId); + error_log("[add_ride] RideID=$insertedId أُسند بالدفعات."); + } else { + error_log("[add_ride] تعذّر الإسناد بالدفعات RideID=$insertedId" + . " — السقوط للبثّ الحر."); + } } - // Broadcast للـ marketplace دائماً - broadcastRideToMarket($insertedId, $startLat, $startLng, $payload, $extraDispatchData); + if (!$batchedStarted) { + if (!empty($driversData)) { + dispatchRideToDrivers($driversData, $insertedId, $payload, $start_name_loc, $encryptionHelper, $extraDispatchData); + error_log("[add_ride] Dispatched RideID=$insertedId to " . count($driversData) . " drivers."); + } else { + error_log("[add_ride] No direct drivers found for RideID=$insertedId — market only."); + } + + // Broadcast للـ marketplace دائماً + broadcastRideToMarket($insertedId, $startLat, $startLng, $payload, $extraDispatchData); + } // رد النجاح للتطبيق printSuccess($insertedId); diff --git a/backend/ride/rides/finish_ride_updates.php b/backend/ride/rides/finish_ride_updates.php index c7eb7925..cd578e20 100644 --- a/backend/ride/rides/finish_ride_updates.php +++ b/backend/ride/rides/finish_ride_updates.php @@ -328,6 +328,17 @@ try { 'passenger_id' => $passengerId, ]); + // ‏ختم نهاية الرحلة — مقياس الخمول في ترتيب محرك الإسناد (وزن ١٥٪، + // ‏إنصافاً لمن انتظر أطول). يُكتب هنا لا عند القبول: السائق يبدأ + // ‏"الخمول" حين يفرغ فعلاً، لا حين ينشغل. + try { + if (isset($redisLocation) && $redisLocation) { + $redisLocation->hSet("driver:score:$driver_id", 'last_ride_end', time()); + } + } catch (Throwable $eScore) { + error_log("[finish_ride_updates] تعذّر ختم نهاية الرحلة: " . $eScore->getMessage()); + } + // 🔥 [Fix Split-Brain] تحديث القاعدة البعيدة الآن فقط، بعد أن أصبح الدفع // والتحديث المحلي مؤكدَين نجاحهما — يبقي الحالتين متطابقتين دائماً. // فشل هذا التحديث best-effort فقط (لا يُرجع الرحلة المحلية المُنجَزة فعلاً). diff --git a/loction_server/driver_socket.php b/loction_server/driver_socket.php index f90acbb6..d307ea83 100755 --- a/loction_server/driver_socket.php +++ b/loction_server/driver_socket.php @@ -42,6 +42,114 @@ function logMsg(string $msg): void { echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL; } +// ============================================================ +// 🎯 الإسناد بالدفعات — سبق حصري قصير ثم إطلاق للجميع +// +// ‏add_ride.php يرتّب المرشحين في Redis ثم يوقظنا. نمنح الأفضل سبقاً +// ‏حصرياً بضع ثوانٍ، وبانتهائه تُطلق الرحلة لكل السائقين المتصلين. +// +// ‏لا سُلّم تنازلي: سُلّم بين ثمانية مرشحين يعني انتظاراً قد يبلغ ثمانين +// ‏ثانية للراكب. هنا التأخير محدود بمدة السبق وحدها، مهما حدث. +// +// ‏مطفأ ما لم يضبط add_ride.php الطابور (أي ما لم يكن DISPATCH_MODE=batched). +// ============================================================ +function dispatchHeadStart(): int { + $v = (int)(getenv('DISPATCH_HEAD_START_SECONDS') ?: 5); + return max(1, min(15, $v)); // يطابق dispatchHeadStartSeconds() في dispatch_helper.php +} + +/** + * ‏يمنح المرشح الأفضل المتصل سبقاً حصرياً، ويجدول الإطلاق للجميع بعده. + * + * ‏المرور على الطابور هنا ليس سُلّماً: هو اختيار أول مرشح متصل فعلاً. + * ‏غير المتصل يُتخطّى فوراً بلا إهدار ثانية واحدة من وقت الراكب. + */ +function dispatchOfferNext($io, string $rideId): void { + global $connectedDrivers; + + $redis = getRedis(); + if (!$redis) return; + + // ‏الرحلة قد تكون قُبلت أو أُلغيت بين تحديدين للمؤقّت. + if (!$redis->exists("ride:$rideId:dispatch_payload")) { + dispatchCleanup($redis, $rideId); + return; + } + + $headStart = dispatchHeadStart(); + $queueKey = "ride:$rideId:dispatch_queue"; + + while (($driverId = $redis->lpop($queueKey)) !== null) { + if (!isset($connectedDrivers[$driverId])) { + logMsg("⏭️ Ride #$rideId — تخطّي السائق #$driverId (غير متصل)"); + continue; + } + + $payload = json_decode((string)$redis->get("ride:$rideId:dispatch_payload"), true) ?: []; + + // ‏وسم العرض: التطبيق يعرضه كـ"طلب خاص" مع عدّاد تنازلي، ليفهم + // ‏السائق أنه صاحب الأولوية الآن لا أن الطلب وصله عشوائياً. + $payload['offer_type'] = 'exclusive'; + $payload['offer_expires_in'] = $headStart; + + // ‏رمز الحصرية: acceptRide.php يرفض أي سائق لا يطابقه خلال السبق. + // ‏TTL ينهي الحصرية تلقائياً حتى لو تعطّل المؤقّت. + $redis->setex("ride:$rideId:current_offer", $headStart, $driverId); + $redis->sadd("ride:offered_drivers:$rideId", $driverId); + + // ‏عدّاد العروض — بسط ومقام معدّل القبول. لا يوجد في MySQL أي سجل + // ‏لعرضٍ لم يُقبل (driver_orders يسجّل المقبول فقط)، فالعدّ هنا هو + // ‏المصدر الوحيد الممكن. cron_driver_score.php يحوّله لمعدّل ويضمحله. + $redis->hincrby("driver:score:$driverId", 'offers', 1); + $redis->zadd('dispatch:active', time() + $headStart, $rideId); + + $io->to('driver_' . $driverId)->emit('new_ride_request', $payload); + logMsg("🎯 Ride #$rideId — سبق {$headStart}ث للسائق #$driverId"); + return; + } + + // ‏لا مرشح متصل إطلاقاً: لا معنى لانتظار أحد — أطلق فوراً. + logMsg("⚡ Ride #$rideId — لا مرشح متصل، إطلاق فوري للجميع"); + dispatchFallbackToBroadcast($io, $redis, $rideId); +} + +/** + * ‏الإطلاق للجميع بعد انتهاء السبق — أو فوراً إن لم يكن هناك مرشح متصل. + * ‏هذا هو السلوك القديم نفسه، فأسوأ حالة في الوضع الجديد = الوضع القديم. + * ‏الحمولة كاملة بالوجهة والإحداثيات — نفس ما يراه صاحب السبق. + */ +function dispatchFallbackToBroadcast($io, $redis, string $rideId): void { + $payload = json_decode((string)$redis->get("ride:$rideId:dispatch_payload"), true) ?: []; + dispatchCleanup($redis, $rideId); + + if (empty($payload)) { + logMsg("⚠️ Ride #$rideId بلا حمولة — تعذّر الإطلاق"); + return; + } + + // ‏انتهى السبق: الطلب صار عاماً للجميع. غياب الوسم في المسارات + // ‏القديمة يعامله التطبيق كعام أيضاً، فلا حاجة لتعديلها. + $payload['offer_type'] = 'public'; + unset($payload['offer_expires_in']); + + global $connectedDrivers; + $count = 0; + foreach (array_keys($connectedDrivers) as $driverId) { + $io->to('driver_' . $driverId)->emit('market_new_ride', $payload); + $count++; + } + logMsg("📢 Ride #$rideId — انتهى السبق، أُطلقت لـ $count سائق"); +} + +/** ‏يمسح كل مفاتيح الإسناد بالدفعات لرحلة انتهت (قُبلت أو أُلغيت أو نفدت). */ +function dispatchCleanup($redis, string $rideId): void { + if (!$redis) return; + $redis->del("ride:$rideId:dispatch_queue"); + $redis->del("ride:$rideId:dispatch_payload"); + $redis->del("ride:$rideId:current_offer"); + $redis->zrem('dispatch:active', $rideId); +} + function loadEnvironment(string $filePath): void { if (!file_exists($filePath)) { logMsg("⚠️ .env not found: $filePath"); @@ -315,6 +423,30 @@ $io = new SocketIO(2020); // ============================================================ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) { + // 🎯 0. مقدِّم طابور الإسناد بالدفعات + // ‏مؤقّت واحد عام لكل الرحلات — لا مؤقّت لكل رحلة، فمئة رحلة متزامنة + // ‏لا تنتج مئة مؤقّت. يمرّ كل ثانية على الرحلات التي انتهى سبقها. + // ‏zset مرتّب بلحظة الانتهاء، فالمرور يلمس المستحقّ فقط لا كل الرحلات. + Timer::add(1, function() use ($io) { + $redis = getRedis(); + if (!$redis) return; + + try { + $due = $redis->zrangebyscore('dispatch:active', 0, time()); + foreach ($due as $rideId) { + // ‏وجود current_offer يعني أن المهلة لم تنتهِ فعلاً بعد + // ‏(فارق تقريب بين score وTTL) — نتركها للمرور التالي. + if ($redis->exists("ride:$rideId:current_offer")) continue; + + $redis->zrem('dispatch:active', $rideId); + logMsg("⏱️ Ride #$rideId — انتهى السبق، إطلاق للجميع"); + dispatchFallbackToBroadcast($io, $redis, (string)$rideId); + } + } catch (\Exception $e) { + logMsg('⚠️ Dispatch sweeper error: ' . $e->getMessage()); + } + }); + // 🚀 1. Redis Pipeline Batch Processor (Level 2) // يعمل كل نصف ثانية، يجمع كل الأوامر ويرسلها لـ Redis دفعة واحدة Timer::add(REDIS_BATCH_INTERVAL, function() { @@ -501,6 +633,17 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) { ); $connection->send(json_encode($results)); + // ── 3c. Start Batched Dispatch ──────────────────────── + // يبنيه add_ride.php في Redis ثم يوقظنا هنا. نحن أصحاب المؤقّت + // لأننا العملية الوحيدة طويلة العمر — كرون كل دقيقة لا يخدم + // مهلة عشر ثوانٍ. + } elseif ($action === 'start_batched_dispatch') { + $rideId = $post['ride_id'] ?? null; + if (!$rideId) { $connection->send('Error: Missing ride_id'); return; } + + dispatchOfferNext($io, (string)$rideId); + $connection->send('OK'); + // ── 4. Ride Taken ───────────────────────────────────── } elseif ($action === 'ride_taken_event') { $rideId = $post['ride_id'] ?? null; @@ -519,6 +662,7 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) { } $redis->del($offeredSetKey); $redis->del("ride:offer:$rideId"); + dispatchCleanup($redis, (string)$rideId); } $io->emit('ride_taken', [ @@ -551,6 +695,7 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) { } $redis->del($offeredSetKey); $redis->del("ride:offer:$rideId"); + dispatchCleanup($redis, (string)$rideId); } // نوحّد قائمة من يجب إشعارهم: السائق الحالي (إن وُجد) + كل من عُرضت عليهم الرحلة diff --git a/siro_driver/lib/controller/home/captin/order_request_controller.dart b/siro_driver/lib/controller/home/captin/order_request_controller.dart index ce4af3e1..077be904 100755 --- a/siro_driver/lib/controller/home/captin/order_request_controller.dart +++ b/siro_driver/lib/controller/home/captin/order_request_controller.dart @@ -49,6 +49,21 @@ class OrderRequestController extends GetxController List? myList; Map? myMapData; + /// نوع الطلب: 'exclusive' خلال ثواني السبق الحصري، 'public' بعد إطلاقه + /// للجميع، و null في وضع البثّ الحر (DISPATCH_MODE غير مضبوط) — عندها + /// لا تُعرض أي شارة. + /// + /// المصدر: حقل offer_type في حمولة السوكِت (new_ride_request / + /// market_new_ride). الطلبات القادمة عبر FCM أو القائمة المسبقة لا تحمله. + Map? get offerInfo { + final t = myMapData?['offer_type']; + if (t == null) return null; + return { + 'offer_type': t.toString(), + 'offer_expires_in': myMapData?['offer_expires_in'], + }; + } + IntaleqMapController? mapController; // الإحداثيات (أزلنا late لتجنب الأخطاء القاتلة) diff --git a/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart b/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart index 3d33d2af..fb5def4c 100755 --- a/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart +++ b/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart @@ -4,6 +4,7 @@ import 'package:intaleq_maps/intaleq_maps.dart'; import 'package:siro_driver/constant/api_key.dart'; import 'package:siro_driver/constant/colors.dart'; import 'package:siro_driver/controller/home/captin/order_request_controller.dart'; +import 'package:siro_driver/views/widgets/offer_type_badge.dart'; import 'package:siro_driver/constant/currency.dart'; import 'package:siro_driver/views/widgets/driver_earnings_badge.dart'; @@ -109,6 +110,11 @@ class OrderRequestPage extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ + // شارة الطلب الخاص/العام — تختفي في وضع البثّ الحر + if (controller.offerInfo != null) ...[ + OfferTypeBadge(rideInfo: controller.offerInfo!), + const SizedBox(width: 10), + ], const Icon(Icons.near_me, color: Colors.amber, size: 16), const SizedBox(width: 8), diff --git a/siro_driver/lib/views/widgets/offer_type_badge.dart b/siro_driver/lib/views/widgets/offer_type_badge.dart new file mode 100644 index 00000000..39bf2cef --- /dev/null +++ b/siro_driver/lib/views/widgets/offer_type_badge.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +/// شارة نوع الطلب: خاص أم عام. +/// +/// الخادم يمنح السائق الأفضل ترتيباً سبقاً حصرياً بضع ثوانٍ قبل أن يُطلق +/// الطلب للجميع (DISPATCH_MODE=batched). بدون هذه الشارة يبدو الفرق +/// عشوائياً للسائق؛ معها يفهم أنه صاحب الأولوية الآن وأن التأخير يكلّفه +/// الطلب. +/// +/// المصدر: حقل `offer_type` في حمولة السوكِت — 'exclusive' أو 'public'. +/// غيابه يعني طلباً عاماً (كل المسارات القديمة والبثّ الحر)، فلا تظهر +/// الشارة إطلاقاً في وضع البثّ العادي. +class OfferTypeBadge extends StatelessWidget { + final Map rideInfo; + + const OfferTypeBadge({Key? key, required this.rideInfo}) : super(key: key); + + bool get _isExclusive => + rideInfo['offer_type']?.toString() == 'exclusive'; + + @override + Widget build(BuildContext context) { + // لا شارة في الوضع العادي — لا نضيف ضجيجاً بصرياً بلا معنى. + if (rideInfo['offer_type'] == null) return const SizedBox.shrink(); + + final Color color = _isExclusive ? Colors.amber.shade800 : Colors.blueGrey; + final IconData icon = _isExclusive ? Icons.star_rounded : Icons.public; + final String label = _isExclusive ? 'طلب خاص لك'.tr : 'طلب عام'.tr; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withOpacity(0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + color: color, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} diff --git a/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripzOverlayService.kt b/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripzOverlayService.kt index f9d35fdb..f5c589b3 100644 --- a/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripzOverlayService.kt +++ b/siro_driver/trip_overlay_plugin/android/src/main/kotlin/com/siro_driver/trip_overlay_plugin/TripzOverlayService.kt @@ -44,6 +44,7 @@ class TripOverlayService : Service() { private var windowManager: WindowManager? = null private var overlayView: View? = null private var countDownTimer: CountDownTimer? = null + private var exclusivityTimer: CountDownTimer? = null private var currentTripId: String = "" private var currentFoodOrderId: String = "" // نوع النافذة المعروضة الآن — يحدّد لمن نُرسل نتيجة القبول/الرفض @@ -79,6 +80,7 @@ class TripOverlayService : Service() { override fun onDestroy() { removeOverlayView() countDownTimer?.cancel() + exclusivityTimer?.cancel() stopSound() // 🔴 إيقاف الصوت عند التدمير isRunning = false super.onDestroy() @@ -176,6 +178,11 @@ class TripOverlayService : Service() { isRunning = true playSound() // 🔴 تشغيل الصوت startCountdown(autoCloseSeconds) + // ‏بعد العدّاد الرئيسي لا قبله: مؤقّتان مستقلان، والحصرية تنتهي + // ‏قبل النافذة بكثير فتقلب الشارة وحدها دون إغلاقها. + if (tripData.offerType == "exclusive") { + startExclusivityCountdown(tripData.offerExpiresIn) + } } catch (e: Exception) { stopSelf() } @@ -430,6 +437,7 @@ class TripOverlayService : Service() { private fun onFoodOrderAccepted() { stopSound() countDownTimer?.cancel() + exclusivityTimer?.cancel() TripOverlayPlugin.notifyFoodOrderAccepted(currentFoodOrderId) bringAppToForeground() removeOverlayView() @@ -553,6 +561,26 @@ class TripOverlayService : Service() { detailsContainer.addView(divider(ctx, 30)) + // ── 3.5 شارة نوع الطلب: خاص أم عام ── + // ‏تظهر فقط حين يرسل الخادم offerType (أي في وضع الإسناد بالدفعات). + // ‏في البثّ الحر تبقى مخفية تماماً فلا تضيف ضجيجاً بلا معنى. + val offerBadge = + TextView(ctx).apply { + gravity = Gravity.CENTER + textSize = 13f + typeface = android.graphics.Typeface.DEFAULT_BOLD + setPadding(24, 12, 24, 12) + visibility = if (trip.offerType.isEmpty()) View.GONE else View.VISIBLE + applyOfferBadgeStyle(this, trip.offerType == "exclusive") + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + .apply { bottomMargin = 24 } + } + detailsContainer.addView(offerBadge) + // ── 4. شريط الوقت ── val countdownLabel = TextView(ctx).apply { @@ -621,7 +649,12 @@ class TripOverlayService : Service() { buttonsRow.addView(acceptBtn) detailsContainer.addView(buttonsRow) - card.tag = mapOf("countdownLabel" to countdownLabel, "progressBar" to progressBar) + card.tag = + mapOf( + "countdownLabel" to countdownLabel, + "progressBar" to progressBar, + "offerBadge" to offerBadge + ) return card } // 🔴 دالة احترافية لقراءة المفتاح المحقون من Gradle 🔴 @@ -763,6 +796,52 @@ class TripOverlayService : Service() { // ========================================================== // 🔴 التحكم والمؤقت 🔴 // ========================================================== + /** + * ‏تنسيق شارة نوع الطلب. الحصري بالكهرماني والعام بالرمادي — الفرق + * ‏اللوني وحده يكفي ليعرف السائق حالته بنظرة بلا قراءة. + */ + private fun applyOfferBadgeStyle(view: TextView, exclusive: Boolean) { + val color = if (exclusive) "#B8860B" else "#607D8B" + val bg = if (exclusive) "#FFF6DC" else "#ECEFF1" + view.text = if (exclusive) "⭐ طلب خاص لك" else "🌐 طلب عام" + view.setTextColor(Color.parseColor(color)) + view.background = + android.graphics.drawable.GradientDrawable().apply { + setColor(Color.parseColor(bg)) + cornerRadius = 40f + setStroke(2, Color.parseColor(color)) + } + } + + /** + * ‏مؤقّت انتهاء الحصرية — منفصل تماماً عن مؤقّت إغلاق النافذة. + * + * ‏بانقضاء ثواني السبق تصبح الرحلة معروضة للجميع، فتنقلب الشارة إلى + * ‏"طلب عام". النافذة **لا تُغلق**: السائق يظل قادراً على القبول، لكنه + * ‏صار يتنافس. الخلط بين المؤقّتين كان سيغلق النافذة بعد خمس ثوانٍ + * ‏ويحرمه الرحلة كلياً. + */ + private fun startExclusivityCountdown(seconds: Int) { + exclusivityTimer?.cancel() + if (seconds <= 0) return + + exclusivityTimer = + object : CountDownTimer(seconds * 1000L, 1000L) { + override fun onTick(millisUntilFinished: Long) { + val left = (millisUntilFinished / 1000).toInt() + val tagMap = overlayView?.tag as? Map<*, *> ?: return + val badge = tagMap["offerBadge"] as? TextView ?: return + badge.text = "⭐ طلب خاص لك — $left ث" + } + override fun onFinish() { + val tagMap = overlayView?.tag as? Map<*, *> ?: return + val badge = tagMap["offerBadge"] as? TextView ?: return + applyOfferBadgeStyle(badge, false) + } + } + .start() + } + private fun startCountdown(totalSeconds: Int) { countDownTimer?.cancel() countDownTimer = @@ -796,6 +875,7 @@ class TripOverlayService : Service() { private fun dismissOverlay(reason: String) { stopSound() // 🔴 countDownTimer?.cancel() + exclusivityTimer?.cancel() if (reason == "rejected" || reason == "timeout") { // النتيجة تذهب لقناة النوع المعروض — لا نخلط عرض توصيل بعرض رحلة if (isFoodOverlay) { @@ -843,7 +923,9 @@ class TripOverlayService : Service() { estimatedFare = obj.getDouble("estimatedFare"), estimatedMinutes = obj.getInt("estimatedMinutes"), pickupLat = obj.getDouble("pickupLat"), - pickupLng = obj.getDouble("pickupLng") + pickupLng = obj.getDouble("pickupLng"), + offerType = obj.optString("offerType", ""), + offerExpiresIn = obj.optInt("offerExpiresIn", 0) ) } catch (e: Exception) { null @@ -859,6 +941,9 @@ class TripOverlayService : Service() { val estimatedFare: Double, val estimatedMinutes: Int, val pickupLat: Double, - val pickupLng: Double + val pickupLng: Double, + // ‏"exclusive" خلال السبق الحصري، "public" بعده، وفارغ في البثّ الحر + val offerType: String = "", + val offerExpiresIn: Int = 0 ) } diff --git a/siro_driver/trip_overlay_plugin/lib/trip_overlay_plugin.dart b/siro_driver/trip_overlay_plugin/lib/trip_overlay_plugin.dart index fcfe44e2..f76aa658 100644 --- a/siro_driver/trip_overlay_plugin/lib/trip_overlay_plugin.dart +++ b/siro_driver/trip_overlay_plugin/lib/trip_overlay_plugin.dart @@ -17,6 +17,14 @@ class TripData { final double pickupLng; final String? passengerAvatarUrl; + /// نوع الطلب: 'exclusive' خلال ثواني السبق الحصري، 'public' بعده، + /// و null في وضع البثّ الحر (عندها لا تظهر أي شارة). + final String? offerType; + + /// ثواني السبق المتبقية. بعد انقضائها تقلب النافذة الشارة إلى "طلب عام" + /// من نفسها — النافذة لا تُغلق، فالسائق يظل قادراً على القبول. + final int offerExpiresIn; + TripData({ required this.tripId, required this.passengerName, @@ -28,6 +36,8 @@ class TripData { required this.pickupLat, required this.pickupLng, this.passengerAvatarUrl, + this.offerType, + this.offerExpiresIn = 0, }); Map toMap() => { @@ -41,6 +51,8 @@ class TripData { 'pickupLat': pickupLat, 'pickupLng': pickupLng, 'passengerAvatarUrl': passengerAvatarUrl ?? '', + 'offerType': offerType ?? '', + 'offerExpiresIn': offerExpiresIn, }; factory TripData.fromMap(Map map) => TripData( @@ -51,6 +63,10 @@ class TripData { distanceKm: (map['distanceKm'] ?? 0.0).toDouble(), estimatedFare: (map['estimatedFare'] ?? 0.0).toDouble(), estimatedMinutes: map['estimatedMinutes'] ?? 0, + offerType: (map['offerType'] ?? '').toString().isEmpty + ? null + : map['offerType'].toString(), + offerExpiresIn: int.tryParse('${map['offerExpiresIn'] ?? 0}') ?? 0, pickupLat: (map['pickupLat'] ?? 0.0).toDouble(), pickupLng: (map['pickupLng'] ?? 0.0).toDouble(), passengerAvatarUrl: map['passengerAvatarUrl'],