Files
Siro/backend/ride/dispatch/dispatch_helper.php
T

230 lines
11 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ============================================================
// dispatch_helper.php — طبقة القرار بين إنشاء الرحلة والسوكِت
//
// ‏الوضع القائم (broadcast): الرحلة تُبَثّ لكل سائق قريب، ويفوز أسرع من
// ‏يضغط — لا الأقرب. سباق يخسره تسعة من عشرة.
//
// ‏الوجهة تبقى ظاهرة كاملة (إحداثيات واسماً) بقرار المالك: التطبيقات ترسم
// ‏بها المسار والخريطة، وللسائق حق معرفة وجهته قبل القبول. معالجة الرفض
// ‏الانتقائي تتم عبر وزن معدّل القبول في الترتيب أدناه، لا بحجب المعلومة.
//
// ‏الوضع الجديد (batched): سبق حصري قصير للمرشح الأفضل، ثم إطلاق للجميع.
//
// ‏قرار مالك المنتج صراحةً: لا سُلّم تنازلي بين المرشحين. سُلّم من ثمانية
// ‏مرشحين بمهلة عشر ثوانٍ لكلٍّ يعني انتظاراً قد يبلغ ثمانين ثانية للراكب —
// ‏وانتظار الراكب هو المؤشر الذي يقتل المنتج، لا عدالة التوزيع بين
// ‏السائقين. التأخير هنا محدود بخمس ثوانٍ ثابتة ومعروفة، ثم يرى الجميع
// ‏الرحلة كما في السلوك القديم.
//
// ⚠️ يلمس أخطر مسار في النظام. لذلك:
// • مطفأ افتراضياً — DISPATCH_MODE غير المضبوط = broadcast (السلوك الحالي حرفياً)
// • قابل للحصر بمدينة/نوع سيارة عبر DISPATCH_BATCHED_CAR_TYPES
// • التراجع فوري: غيّر متغير البيئة وأعد تشغيل php — بلا ترحيل ولا نشر
//
// ‏لا يضيف هذا الملف أي استعلام MySQL: كل إشارات الترتيب تأتي من Redis أو
// ‏من بيانات findBestDrivers() المحمّلة أصلاً. الإشارة الغائبة تُحسب محايدة.
// ============================================================
/**
* ‏مدة السبق الحصري بالثواني قبل الإطلاق للجميع.
*
* ‏متغيّر لا ثابت عمداً: خمس ثوانٍ قد لا تكفي عملياً — رد فعل الإنسان على
* ‏إشعار يستغرق ٢-٥ ثوانٍ، فقد ينتهي السبق قبل أن يرفع السائق هاتفه فيصير
* ‏المحرك بلا أثر. ابدأ بخمس، وارفعها إن أظهرت السجلات أن السائق الأول
* ‏نادراً ما يقبل خلالها — بلا تعديل كود.
*/
function dispatchHeadStartSeconds(): int
{
$v = (int) (getenv('DISPATCH_HEAD_START_SECONDS') ?: 5);
// ‏سقف صلب: أي رقم أعلى يعيد مشكلة انتظار الراكب التي أُلغي السُّلّم بسببها.
return max(1, min(15, $v));
}
// ‏عدد المرشحين المحفوظين. ليس سُلّماً: يُستخدم فقط لاختيار صاحب السبق إن
// ‏كان الأول غير متصل لحظتها — الانتقال بينهم فوري بلا أي انتظار إضافي.
const DISPATCH_MAX_CANDIDATES = 3;
/**
* هل نستخدم الإسناد بالدفعات لهذه الرحلة؟
*
* DISPATCH_MODE=batched يفعّله، وأي قيمة أخرى (أو غيابها) تبقي البثّ الحر.
* DISPATCH_BATCHED_CAR_TYPES يحصره بأنواع سيارات بعينها للتجربة التدريجية،
* مثلاً "Speed" لتجريبه على نوع واحد قبل التعميم. فارغ = كل الأنواع.
*/
function dispatchIsBatched(string $carType): bool
{
if (strtolower((string) getenv('DISPATCH_MODE')) !== 'batched') {
return false;
}
$allowed = trim((string) getenv('DISPATCH_BATCHED_CAR_TYPES'));
if ($allowed === '') {
return true;
}
$list = array_filter(array_map('trim', explode(',', $allowed)));
return in_array($carType, $list, true);
}
/**
* درجة المرشح — كلما ارتفعت كان أولى بالعرض.
*
* الأوزان مقصودة كنقطة انطلاق قابلة للمعايرة بعد أول أسبوع تشغيل، لا
* كأرقام نهائية. كل مكوّن مُطبَّع إلى [0,1] فيبقى المجموع مفهوماً.
*
* @param array $driver صف من findBestDrivers()
* @param array $signals إشارات مقروءة من Redis (قد تكون فارغة)
*/
function dispatchScoreDriver(array $driver, array $signals): float
{
// ── ١. القرب (40%) — بديل عملي عن زمن الوصول الفعلي.
// ‏المسافة الهوائية ليست ETA حقيقياً، لكنها الإشارة الوحيدة المتاحة بلا
// ‏نداء OSRM لكل مرشح. ترقية هذا إلى ETA حقيقي هي أول تحسين بعد الإطلاق.
$distanceKm = (float) ($driver['distance_km'] ?? 999);
$proximity = $distanceKm >= 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;
}