getMessage()); printFailure("Database connection failed"); exit; } // ================================================================================= // 🛠️ دالة مساعدة: إرسال الرحلة لسوق السائقين (Marketplace Broadcast) // ================================================================================= /** * ‏حمولة الرحلة كما يراها السائق قبل القبول. * * ‏مشتركة بين البثّ الحر والإسناد بالدفعات عمداً: أي حقل يظهر في أحدهما * ‏ولا يظهر في الآخر يصل للسيرفر لاحقاً كنص "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, 'end_lat' => $payloadData[3], 'end_lng' => $payloadData[4], 'start_location' => $lat . ',' . $lng, 'end_location' => $payloadData[3] . ',' . $payloadData[4], 'price' => $payloadData[2], 'carType' => $payloadData[31], 'startName' => $payloadData[29], 'endName' => $payloadData[30], 'distance' => $payloadData[11], 'duration' => $payloadData[15], 'passengerRate' => $payloadData[33], 'passengerId' => $payloadData[7], // ‏رحلة محجوزة: يقرأها available_rides_page و order_request_page // ‏ونافذة الأوفرلي. غيابها = رحلة لحظية عادية. 'is_scheduled' => $payloadData[39] ?? '', 'scheduled_at' => $payloadData[40] ?? '', // ‏راكب بلا إنترنت: طلب عبر رسالة نصية. لن يصله شات ولا إشعار، // ‏والاتصال الهاتفي هو وسيلة التواصل الوحيدة معه. 'is_sms' => $payloadData[41] ?? '', ], $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', 'payload' => json_encode($marketPayload) ]; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData)); 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); } error_log("[add_ride] Request started. passenger_id=" . ($_POST['passenger_id'] ?? '?')); // ── 1. Input ─────────────────────────────────────────────────── // ‏وسم الحجز المسبق: يمرّره bot/cron_scheduled_rides.php حين يحوّل حجزاً // ‏لرحلة فعلية. الرحلة العادية تتركه فارغاً فلا يظهر شيء في التطبيقات. $isScheduledRide = filterRequest("source") === 'scheduled'; $scheduledAtLabel = filterRequest("scheduled_at") ?: ''; // ‏وسم الطلب بالرسائل النصية: يمرّره sms/ride_request.php لراكب لا // ‏يملك إنترنت. تطبيق السائق يقرأ الوسم ليعرف أن التواصل بالاتصال لا // ‏بالمحادثة — الراكب لن يرى أي إشعار في التطبيق. $isSmsRide = filterRequest("source") === 'sms'; $start_location = filterRequest("start_location"); $end_location = filterRequest("end_location"); $price = filterRequest("price"); $price_token = filterRequest("price_token"); // ══════════════════════════════════════════════════════════════ // 🔐 مسار S2S للحجز المسبق — بوّابة مزدوجة لا مفتاح وحده // // ‏bot/cron_scheduled_rides.php لا يملك JWT راكب ولا رمز سعر، فلا يمكنه // ‏المرور من الحراسة العادية. لكن فتح استثناء بمفتاح S2S وحده يعني أن // ‏تسريب المفتاح = إنشاء رحلات باسم أي راكب. // // ‏لذلك شرطان معاً: // ١. مفتاح S2S صحيح // ٢. الحجز موجود فعلاً في scheduled_rides بحالة 'dispatching'، ويخصّ // هذا الراكب بالذات، وبنفس نقطة الانطلاق // // ‏الشرط الثاني هو الحماية الحقيقية: لا رحلة تُنشأ إلا مقابل حجز أنشأه // ‏الراكب بنفسه عبر المسار الموثَّق، والكرون قفله للتوّ. حامل المفتاح // ‏وحده لا يستطيع اختلاق حجز. // ══════════════════════════════════════════════════════════════ $isInternalScheduled = false; // ‏يُملأ أحدهما فقط حسب المصدر. التصريح بهما هنا يمنع تحذير «متغيّر // ‏غير معرّف» في كتلة التسعير الداخلي التي تقرأ الاثنين. $booking = null; $smsReq = null; if ($isScheduledRide) { $providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? ''; $expectedKey = getenv('S2S_SHARED_KEY') ?: ''; $scheduledId = (int) (filterRequest('scheduled_id', 'int') ?: 0); $claimedPassenger = filterRequest('passenger_id'); if (empty($expectedKey) || !hash_equals($expectedKey, (string) $providedKey)) { error_log('[add_ride] SECURITY: مسار الحجز بمفتاح S2S غير صالح'); printFailure('Unauthorized'); exit; } if ($scheduledId <= 0 || empty($claimedPassenger)) { printFailure('Missing scheduled_id or passenger_id'); exit; } try { $stmtBooking = $con->prepare(" SELECT passenger_id, start_location, end_location, car_type, distance, duration FROM scheduled_rides WHERE id = ? AND status = 'dispatching' LIMIT 1 "); $stmtBooking->execute([$scheduledId]); $booking = $stmtBooking->fetch(PDO::FETCH_ASSOC); } catch (PDOException $eB) { error_log('[add_ride] تعذّرت قراءة الحجز: ' . $eB->getMessage()); printFailure('Server error'); exit; } if (!$booking) { error_log("[add_ride] SECURITY: حجز #$scheduledId غير موجود أو ليس قيد الإطلاق"); printFailure('Invalid booking'); exit; } if ((string) $booking['passenger_id'] !== (string) $claimedPassenger || !coordsMatch($booking['start_location'], $start_location)) { error_log("[add_ride] SECURITY: حجز #$scheduledId لا يطابق الراكب أو النقطة"); printFailure('Booking mismatch'); exit; } // ‏من هنا نثق ببيانات **الحجز المخزَّن** لا بما وصل في الطلب. $isInternalScheduled = true; $passenger_id = $booking['passenger_id']; $end_location = $booking['end_location']; $carType = $booking['car_type']; $distance = $booking['distance']; } elseif ($isSmsRide) { // ══════════════════════════════════════════════════════════ // ‏نفس البوّابة المزدوجة بالضبط، على جدول sms_ride_requests. // // ‏الفرق عن الحجز المسبق أن مصدر الطلب هنا رسالة نصية من رقم لا // ‏يحمل JWT إطلاقاً — فالشرط الثاني هو كل الحماية: لا رحلة إلا // ‏مقابل صف أنشأه الويب‌هوك من رسالة فعلية وقفله للتوّ. // ══════════════════════════════════════════════════════════ $providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? ''; $expectedKey = getenv('S2S_SHARED_KEY') ?: ''; $smsRequestId = (int) (filterRequest('sms_request_id', 'int') ?: 0); $claimedPassenger = filterRequest('passenger_id'); if (empty($expectedKey) || !hash_equals($expectedKey, (string) $providedKey)) { error_log('[add_ride] SECURITY: مسار SMS بمفتاح S2S غير صالح'); printFailure('Unauthorized'); exit; } if ($smsRequestId <= 0 || empty($claimedPassenger)) { printFailure('Missing sms_request_id or passenger_id'); exit; } try { $stmtSms = $con->prepare(" SELECT passenger_id, start_location, end_location, distance, duration FROM sms_ride_requests WHERE id = ? AND status = 'dispatching' LIMIT 1 "); $stmtSms->execute([$smsRequestId]); $smsReq = $stmtSms->fetch(PDO::FETCH_ASSOC); } catch (PDOException $eS) { error_log('[add_ride] تعذّرت قراءة طلب SMS: ' . $eS->getMessage()); printFailure('Server error'); exit; } if (!$smsReq) { error_log("[add_ride] SECURITY: طلب SMS #$smsRequestId غير موجود أو ليس قيد الإطلاق"); printFailure('Invalid sms request'); exit; } if ((string) $smsReq['passenger_id'] !== (string) $claimedPassenger || !coordsMatch($smsReq['start_location'], $start_location)) { error_log("[add_ride] SECURITY: طلب SMS #$smsRequestId لا يطابق الراكب أو النقطة"); printFailure('SMS request mismatch'); exit; } // ‏نثق بالصف المخزَّن لا بما وصل في الطلب. $isInternalScheduled = true; // ‏نفس الإعفاء من رمز السعر و JWT $passenger_id = $smsReq['passenger_id']; $end_location = $smsReq['end_location']; $distance = $smsReq['distance']; } else { // Force passenger_id from JWT — never trust user-supplied passenger_id $passenger_id = $user_id; } $driver_id = (string)(filterRequest("driver_id") ?: '0'); $status = filterRequest("status") ?: 'nothing'; $price_for_driver = filterRequest("price_for_driver") ?: ($price ?: '0'); $price_for_passenger = filterRequest("price_for_passenger") ?: ($price ?: '0'); $distance = filterRequest("distance") ?: 0; $carType = filterRequest("carType"); $passenger_name = filterRequest("passenger_name"); $passenger_phone = filterRequest("passenger_phone"); $passenger_token = filterRequest("passenger_token"); $passenger_email = filterRequest("passenger_email"); $passenger_wallet = filterRequest("passenger_wallet"); $passenger_rating = filterRequest("passenger_rating"); $start_name_loc = filterRequest("start_name"); $end_name_loc = filterRequest("end_name"); $duration_text = filterRequest("duration_text"); $distance_text = filterRequest("distance_text"); $is_wallet = filterRequest("is_wallet"); $has_steps = filterRequest("has_steps"); $step0 = filterRequest("step0"); $step1 = filterRequest("step1"); $step2 = filterRequest("step2"); $step3 = filterRequest("step3"); $step4 = filterRequest("step4"); // Helper to compare coordinates (allowing slight GPS precision drift up to ~500m) function coordsMatch($coordStr1, $coordStr2, $tolerance = 0.005) { if (empty($coordStr1) || empty($coordStr2)) return false; $c1 = array_map('floatval', explode(',', $coordStr1)); $c2 = array_map('floatval', explode(',', $coordStr2)); if (count($c1) < 2 || count($c2) < 2) return false; return (abs($c1[0] - $c2[0]) < $tolerance) && (abs($c1[1] - $c2[1]) < $tolerance); } // Validation if (empty($passenger_id) || empty($start_location) || empty($end_location) || empty($price)) { error_log("[add_ride] Validation failed — missing required fields."); printFailure("Missing required fields"); exit; } // ── التسعير الداخلي لمسار الحجز ───────────────────────────── // ‏السعر لا يأتي من الطلب: الكرون يمرّر تقدير وقت الحجز، وهو قديم بساعات // ‏ولا يعرف الذروة. نحسبه هنا من أسعار الدولة لحظة الإنشاء. if ($isInternalScheduled) { require_once __DIR__ . '/../pricing/pricing_helper.php'; $kazanRow = []; try { $stmtK = $con->prepare("SELECT * FROM kazan WHERE country = ? LIMIT 1"); $stmtK->execute([getenv('GLOBAL_COUNTRY') ?: 'Jordan']); $kazanRow = $stmtK->fetch(PDO::FETCH_ASSOC) ?: []; } catch (PDOException $eK) { error_log('[add_ride] تعذّر جلب أسعار الدولة للحجز: ' . $eK->getMessage()); } if (empty($kazanRow)) { printFailure('Pricing unavailable'); exit; } // ‏المدّة من الصف المخزَّن أيّاً كان مصدره — الحجز المسبق أو طلب // ‏الرسائل. قراءتها من `$booking` وحده كانت تمرّر صفراً لكل رحلة // ‏قادمة من SMS، فيسقط شقّ الزمن من السعر. $internalDuration = (int) ($booking['duration'] ?? $smsReq['duration'] ?? 0); $price = computeInternalRidePrice( $kazanRow, (string) $carType, (float) $distance, $internalDuration ); $price_for_driver = $price; $price_for_passenger = $price; $internalRef = $isSmsRide ? "طلب SMS #$smsRequestId" : "حجز #$scheduledId"; error_log("[add_ride] $internalRef سُعِّر داخلياً: $price"); } // SECURE PRICE TOKEN VERIFICATION if (!$isInternalScheduled && empty($price_token)) { error_log("[add_ride] Security failed — price_token is missing."); printFailure("Secure price token is required"); exit; } $decrypted = (!$isInternalScheduled && isset($encryptionHelper)) ? $encryptionHelper->decryptData($price_token) : false; if (!$isInternalScheduled && !$decrypted) { error_log("[add_ride] Security failed — failed to decrypt price_token."); printFailure("Invalid or tampered price token"); exit; } $tokenData = $decrypted ? json_decode($decrypted, true) : null; if (!$isInternalScheduled && (!$tokenData || !isset($tokenData['expires']) || $tokenData['expires'] < time())) { error_log("[add_ride] Security failed — token is expired or invalid JSON."); printFailure("Price token has expired, please request estimation again"); exit; } if (!$isInternalScheduled && $tokenData['passenger_id'] != $passenger_id) { error_log("[add_ride] Security failed — passenger_id mismatch."); printFailure("Tampered price token (passenger mismatch)"); exit; } if (!$isInternalScheduled && (!coordsMatch($tokenData['start_location'], $start_location) || !coordsMatch($tokenData['end_location'], $end_location))) { error_log("[add_ride] Security failed — coordinates mismatch. Token: " . ($tokenData['start_location'] . " / " . $tokenData['end_location']) . " Request: " . ($start_location . " / " . $end_location)); printFailure("Tampered price token (route mismatch)"); exit; } // ✅ FIX P6: خريطة أسماء car types بين التطبيق والـ token // التطبيق يرسل أسماء عرض (Fixed Price, Scooter...) لكن الـ token يخزن أماً داخلية (Speed, Delivery...) $displayToTokenCarType = [ 'Fixed Price' => 'Speed', 'Rayeh Gai' => 'Speed', 'Scooter' => 'Delivery', 'Pink Bike' => 'Delivery', ]; $tokenCarType = isset($displayToTokenCarType[$carType]) ? $displayToTokenCarType[$carType] : $carType; if (!isset($tokenData['prices'][$tokenCarType])) { error_log("[add_ride] Security failed — car type $carType (token key: $tokenCarType) not found in token."); printFailure("Invalid car type for this token"); exit; } // ✅ FIX P2: تم حذف التحقق من distance و duration // السبب: token['distance'] هو الإحداثيات بينما $distance هو المسافة بالكيلومتر (0.x) // وtoken['duration'] هو الثواني بينما $duration_text هو الدقائق — mismatch دائم يكسر جميع الرحلات // الإحداثيات كافية للتحقق من سلامة الطلب عبر coordsMatch() أعلاه // Securely override pricing from the cryptographically signed token $price = $tokenData['prices'][$tokenCarType]['price']; $price_for_driver = $tokenData['prices'][$tokenCarType]['driver_price']; $price_for_passenger = $price; // 🆕 Destination Matching $is_destination_match = isset($tokenData['is_destination_match']) ? (int)$tokenData['is_destination_match'] : 0; $matched_driver_id = isset($tokenData['matched_driver_id']) ? $tokenData['matched_driver_id'] : 0; // 👑 Siro Prime Status $is_prime = isset($tokenData['is_prime']) ? (int)$tokenData['is_prime'] : 0; // ── 2. تنسيق التواريخ ───────────────────────────────────────── $date_formatted = date("Y-m-d"); $time_formatted = date("H:i:s"); $endtime_formatted = filterRequest("endtime") ? date("H:i:s", strtotime(filterRequest("endtime"))) : "00:00:00"; // ── 3. إحداثيات البداية والنهاية ────────────────────────────── $startLat = $startLng = $endLat = $endLng = ""; if (!empty($start_location)) { [$startLat, $startLng] = array_map('trim', explode(',', $start_location, 2)); } if (!empty($end_location)) { [$endLat, $endLng] = array_map('trim', explode(',', $end_location, 2)); } // ── 4. مصفوفة بيانات الإدخال ────────────────────────────────── $insertData = [ ':start_location' => $start_location, ':end_location' => $end_location, ':date' => $date_formatted, ':time' => $time_formatted, ':endtime' => $endtime_formatted, ':price' => $price, ':passenger_id' => $passenger_id, ':driver_id' => $driver_id, ':status' => $status, ':carType' => $carType, ':price_for_driver' => $price_for_driver, ':price_for_passenger' => $price_for_passenger, ':distance' => $distance, ':is_destination_match' => $is_destination_match, ]; $sqlInsert = "INSERT INTO `ride` (`start_location`,`end_location`,`date`,`time`,`endtime`, `price`,`passenger_id`,`driver_id`,`status`,`carType`, `price_for_driver`,`price_for_passenger`,`distance`,`is_destination_match`) VALUES (:start_location,:end_location,:date,:time,:endtime, :price,:passenger_id,:driver_id,:status,:carType, :price_for_driver,:price_for_passenger,:distance,:is_destination_match)"; try { // ═══════════════════════════════════════════════════════════ // STEP A — ride DB أولاً (هو المرجع الأساسي) // ═══════════════════════════════════════════════════════════ $stmtRide = $con_ride->prepare($sqlInsert); $stmtRide->execute($insertData); $insertedId = $con_ride->lastInsertId(); if (!$insertedId) { error_log("[add_ride] ride DB insert returned no ID."); printFailure("Failed to create ride"); exit; } error_log("[add_ride] ride DB insert success. RideID=$insertedId"); // 🆕 Seed initial ride state in Redis — fired as early as possible so // getRideStatus polling has a cache entry from the first poll onward. // Uses $status/$driver_id/$passenger_id, the exact values just written // to MySQL above, not hardcoded literals. sendToLocationServer('update_ride_state', [ 'ride_id' => $insertedId, 'status' => $status, 'driver_id' => $driver_id, 'passenger_id' => $passenger_id, ]); // ═══════════════════════════════════════════════════════════ // STEP B — primary DB ثانياً (نسخة أرشيفية بنفس الـ ID) // ═══════════════════════════════════════════════════════════ $sqlInsertWithId = "INSERT INTO `ride` (`id`,`start_location`,`end_location`,`date`,`time`,`endtime`, `price`,`passenger_id`,`driver_id`,`status`,`carType`, `price_for_driver`,`price_for_passenger`,`distance`,`is_destination_match`) VALUES (:id,:start_location,:end_location,:date,:time,:endtime, :price,:passenger_id,:driver_id,:status,:carType, :price_for_driver,:price_for_passenger,:distance,:is_destination_match)"; try { $primaryData = $insertData; $primaryData[':id'] = $insertedId; $stmtPrimary = $con->prepare($sqlInsertWithId); $stmtPrimary->execute($primaryData); error_log("[add_ride] primary DB sync success. RideID=$insertedId"); } catch (PDOException $ePrimary) { // لا نوقف العملية — ride DB هو المرجع error_log("[add_ride] primary DB sync WARNING: " . $ePrimary->getMessage()); } // ═══════════════════════════════════════════════════════════ // STEP C — بناء الـ payload وإرسال الرحلة للسائقين // ═══════════════════════════════════════════════════════════ $kazan = (float) $price - (float) $price_for_driver; $passengerFp = isset($_SERVER['HTTP_X_DEVICE_FP']) ? $_SERVER['HTTP_X_DEVICE_FP'] : ''; // ── 🆕 "أرباحك أعلى" — مقارنة أجرة السائق عندنا مع أقرب منافس ───── require_once __DIR__ . '/../pricing/pricing_helper.php'; $extraDispatchData = []; try { $rideCountryCode = 'JO'; $stmtCountry = $con->prepare(" SELECT country_code FROM passenger_opening_locations WHERE passenger_id = :pid ORDER BY created_at DESC LIMIT 1 "); $stmtCountry->execute([':pid' => $passenger_id]); $foundCountry = $stmtCountry->fetchColumn(); if ($foundCountry) $rideCountryCode = strtoupper($foundCountry); $durationMinForEarnings = is_numeric($duration_text) ? (float)$duration_text : 0.0; $earnings = estimateDriverEarningsAdvantage( $rideCountryCode, (float)$distance, $durationMinForEarnings, (float)$price_for_driver, $redis ?? null ); if ($earnings) { $extraDispatchData['driver_earnings_extra'] = (string)$earnings['extra']; $extraDispatchData['driver_earnings_currency'] = $earnings['currency']; } } catch (Exception $e) { error_log("[add_ride] Driver earnings estimate failed: " . $e->getMessage()); } $payload = [ (string) $startLat, (string) $startLng, number_format((float) $price, 2, '.', ''), (string) $endLat, (string) $endLng, (string) $distance_text, (string) $passengerFp, (string) $passenger_id, (string) $passenger_name, (string) $passenger_token, (string) $passenger_phone, (string) $distance, "1", (string) $is_wallet, (string) $distance, (string) $duration_text, (string) $insertedId, "", "", (string) $duration_text, $has_steps ?: 'false', (string) $step0, (string) $step1, (string) $step2, (string) $step3, (string) $step4, number_format((float) $price_for_driver, 2, '.', ''), (string) $passenger_wallet, (string) $passenger_email, (string) $start_name_loc, (string) $end_name_loc, (string) $carType, number_format($kazan, 2, '.', ''), (string) $passenger_rating, (string) $is_prime, // 👑 Index 34: Prime Status // 🆕 Index 35/36: "أرباحك أعلى" — تُقرأ من نافذة الـ Overlay (order_over_lay.dart) // اللي بتوصلها البيانات كـ List مش كـ Map مسمّى مثل FCM isset($extraDispatchData['driver_earnings_extra']) ? $extraDispatchData['driver_earnings_extra'] : '', isset($extraDispatchData['driver_earnings_currency']) ? $extraDispatchData['driver_earnings_currency'] : '', // 🆕 Index 37/38: وسم العرض الحصري في الإسناد بالدفعات '', '', // 🆕 Index 39/40: رحلة محجوزة مسبقاً — السائق يجب أن يعرف أنها // ليست طلباً لحظياً بل موعد مضبوط، وأن الراكب ينتظره في وقت محدد. $isScheduledRide ? '1' : '', $isScheduledRide ? (string) $scheduledAtLabel : '', // 🆕 Index 41: طلب وصل عبر رسالة نصية — الراكب بلا إنترنت. // السائق يجب أن يعرف أن المحادثة داخل التطبيق لن تصل إليه، // وأن الاتصال الهاتفي هو وسيلة التواصل الوحيدة معه. $isSmsRide ? '1' : '', ]; // Direct dispatch للسائقين القريبين $driversData = findBestDrivers($con, $startLat, $startLng, $carType, $endLat, $endLng); // ═══════════════════════════════════════════════════════════════ // 🆕 Destination Matching Priority — Multi-Driver Support // نبحث عن كل السائقين الذين وجهتهم تطابق منطقة الإنزال // (ليس فقط الأول، بل جميعهم) ونضعهم في مقدمة القائمة // الخصم 14% تم تطبيقه مسبقاً في التسعير — لا خصم إضافي هنا // ═══════════════════════════════════════════════════════════════ if ($is_destination_match && isset($redis)) { try { // Query ALL drivers whose destination is near the drop-off (up to 10) $allMatchedIds = $redis->geoRadius( 'geo:driver:destinations', (float)$destLng, (float)$destLat, 3.5, 'km', ['COUNT' => 10, 'ASC'] ); if (!empty($allMatchedIds)) { // Build a lookup set for fast de-duplication $alreadyInList = []; foreach ($driversData as $d) { $alreadyInList[$d['captain_id'] ?? $d['driver_id'] ?? ''] = true; } $matchedToFront = []; foreach ($allMatchedIds as $mid) { $mid = (string)$mid; // Validate still active today $detailJson = $redis->get("driver:destination:{$mid}"); $detail = $detailJson ? json_decode($detailJson, true) : null; if (!$detail || empty($detail['is_active']) || ($detail['usage_date'] ?? '') !== date('Y-m-d')) { $redis->zRem('geo:driver:destinations', $mid); continue; } // Mark with destination flag for special FCM title "في طريقك 📍" if (isset($alreadyInList[$mid])) { // Already in list — mark it and move to front foreach ($driversData as $k => $d) { $did = $d['captain_id'] ?? $d['driver_id'] ?? ''; if ((string)$did === $mid) { $driversData[$k]['is_destination_match'] = 1; $matchedToFront[] = $driversData[$k]; unset($driversData[$k]); break; } } } else { // Not nearby but their route matches — fetch token and add $stmtD = $con->prepare("SELECT token FROM driverToken WHERE captain_id = :d LIMIT 1"); $stmtD->execute([':d' => $mid]); $dT = $stmtD->fetchColumn(); if ($dT) { $matchedToFront[] = [ 'captain_id' => $mid, 'driver_id' => $mid, 'token' => $dT, 'is_destination_match' => 1 ]; } } } // Place all destination-matched drivers at the front $driversData = array_values($driversData); $driversData = array_merge($matchedToFront, $driversData); error_log("[add_ride] " . count($matchedToFront) . " destination-matched driver(s) moved to front."); } } catch (Exception $e) { error_log("[add_ride] Destination priority error: " . $e->getMessage()); } } // ═══════════════════════════════════════════════════════════ // الإسناد — بالدفعات أو بالبثّ الحر // // ‏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); // ‏السوكِت يوصل العرض لمن تطبيقه مفتوح فقط. بلا FCM، السائق // ‏الأفضل ترتيباً وتطبيقه مغلق لا يعلم بسبقه إطلاقاً — فيضيع // ‏السبق كله ويذهب للبثّ. نرسل له إشعاراً موازياً. $headStartDriver = dispatchHeadStartDriver($redisLocation ?? null, $insertedId); if ($headStartDriver !== null) { $headDriverRow = null; foreach ($driversData as $d) { if ((string)($d['driver_id'] ?? '') === $headStartDriver) { $headDriverRow = $d; break; } } if ($headDriverRow) { dispatchRideToDrivers( [$headDriverRow], $insertedId, $payload, $start_name_loc, $encryptionHelper, $extraDispatchData, ['exclusive', dispatchHeadStartSeconds()] ); } } error_log("[add_ride] RideID=$insertedId أُسند بالدفعات."); } else { error_log("[add_ride] تعذّر الإسناد بالدفعات RideID=$insertedId" . " — السقوط للبثّ الحر."); } } 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); } catch (PDOException $e) { error_log("[add_ride] CRITICAL ride DB error: " . $e->getMessage()); printFailure("Database error: " . $e->getMessage()); }