= 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; $score = (0.40 * $proximity) + (0.20 * $acceptRate) + (0.15 * $rating) + (0.15 * $idle) + (0.10 * $destinationFit); // ── عقوبة التسرب خارج المنصة ── // ‏يحسبها bot/cron_leak_detection.php من نمط: قَبِل ← أُلغيت ← لم تبدأ، // ‏مع تكرار نفس زوج (سائق، راكب). ليست حظراً بل خفض أولوية: السائق // ‏يخسر السبق والرحلات الأقرب تدريجياً — موجع بلا حرمان، ويتراجع من // ‏نفسه حين ينظف سجله لأن الكرون يحذف الحقل خارج النافذة. // // ‏تُطرح من الدرجة لا تُضرب فيها: الضرب كان سيبقي السائق الأقرب // ‏متقدّماً رغم عقوبته، والطرح يزيحه فعلاً عن رأس القائمة. $leakPenalty = isset($signals['leak_penalty']) ? max(0.0, min(1.0, (float) $signals['leak_penalty'])) : 0.0; return round(max(0.0, $score - (0.35 * $leakPenalty)), 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, 'leak_penalty' => $row['leak_penalty'] ?? null, ]; } } catch (Throwable $e) { error_log("[dispatch] تعذّرت قراءة إشارات الترتيب: " . $e->getMessage()); } return $signals; } /** * ‏أول مرشح في الطابور — أي صاحب السبق المتوقّع. * * ‏يُقرأ بلا إخراج (lIndex لا lPop): السوكِت هو من يسحب من الطابور، وسحبنا * ‏منه هنا كان سيحرم الأول من عرضه. * * ⚠️ ‏قد يتخطّاه السوكِت إن كان غير متصل، فيصير السبق لغيره بينما وصل هذا * ‏إشعار FCM. لا ضرر: تطبيقه سيجد الرحلة مأخوذة أو عامة، والسبق ينتهي * ‏بعد ثوانٍ على أي حال. */ function dispatchHeadStartDriver($redisLocation, $rideId): ?string { if (!$redisLocation) { return null; } try { $first = $redisLocation->lIndex("ride:$rideId:dispatch_queue", 0); return ($first === false || $first === null) ? null : (string) $first; } catch (Throwable $e) { error_log("[dispatch] تعذّرت قراءة صاحب السبق RideID=$rideId: " . $e->getMessage()); return null; } } /** * ‏يرتّب المرشحين ويسلّمهم للسوكِت ليمنح الأفضلَ سبقاً حصرياً قصيراً. * * @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; }