Update: 2026-08-07 01:35:27

This commit is contained in:
Hamza-Ayed
2026-08-07 01:35:27 +03:00
parent 0cb1ab0ea3
commit 5b864e30df
11 changed files with 843 additions and 26 deletions
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* cron_driver_score.php
* ─────────────────────────────────────────────────────────────
* يغذّي إشارات ترتيب محرك الإسناد في مفاتيح driver:score:* على Redis.
*
* بدون هذا الكرون يعمل المحرك بالقرب وملاءمة الوجهة فقط، وتُحسب بقية
* المكوّنات محايدة (٠٫٥) — أي أن ٥٠٪ من وزن الترتيب معطّل عملياً:
* • معدّل القبول ٢٠٪ ← يُحسب هنا
* • التقييم ١٥٪ ← يُحسب هنا
* • مدة الخمول ١٥٪ ← يختمها finish_ride_updates.php، لا نلمسها
*
* ── معدّل القبول ──
* لا يوجد في MySQL أي سجل لعرضٍ لم يُقبل: driver_orders يسجّل المقبول
* فقط. لذلك يُعدّ العرض في السوكِت (حقل offers) والقبول في acceptRide.php
* (حقل accepts)، وهذا الكرون يحوّلهما لنسبة.
*
* الاضمحلال: نضرب العدّادين بمعامل كل تشغيل، فتصير النسبة نافذة متحرّكة
* تعكس السلوك الأخير لا سجلّ العمر كله. سائق أصلح سلوكه يتعافى خلال أيام
* بدل أن يظل مسجوناً في ماضيه.
*
* تنعيم Laplace: سائق عُرض عليه عرضان وقَبِل واحداً لا يستحق ٥٠٪ بثقة
* كاملة. نضيف عروضاً وهمية محايدة فتقترب نسبته من المحايد حتى تتراكم
* بيانات كافية.
*
* جدولة مقترحة (crontab): كل ربع ساعة — الإشارات بطيئة التغيّر ولا تستحق أكثر.
* الحقل الأول في السطر: الشرطة المائلة ثم 15
* ثم: docker compose exec -T php php /var/www/backend/bot/cron_driver_score.php
*
* ⚠️ يجب أن يعمل داخل حاوية php: Redis لا ينشر منافذ، فتشغيله على المضيف
* يعطي $redisLocation = null ويخرج بلا عمل.
*/
require_once __DIR__ . '/../core/bootstrap.php';
// ── معاملات المعايرة ──
const SCORE_DECAY = 0.85; // ‏ما يبقى من العدّادات كل تشغيل
const SCORE_PRIOR_OFFERS = 4.0; // ‏عروض وهمية للتنعيم
const SCORE_PRIOR_ACCEPTS = 2.0; // ‏منها مقبولة — أي محايد ٠٫٥
const SCORE_KEY_TTL = 2592000; // 30 يوماً: سائق اختفى يُنسى وحده
if (!isset($redisLocation) || !$redisLocation) {
fwrite(STDERR, "[driver_score] Redis غير متاح — هل يعمل الكرون داخل حاوية php؟\n");
exit(1);
}
try {
$con = Database::get('main');
} catch (Exception $e) {
fwrite(STDERR, "[driver_score] DB unavailable: " . $e->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";
+229
View File
@@ -0,0 +1,229 @@
<?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;
}
+37
View File
@@ -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 (بعد نجاح القفل)
// ═══════════════════════════════════════════════════════════
+96 -23
View File
@@ -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);
@@ -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 فقط (لا يُرجع الرحلة المحلية المُنجَزة فعلاً).