Files

741 lines
36 KiB
PHP
Raw Permalink 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
// ═══════════════════════════════════════════════════════════════
// passenger/ride/add_ride.php
// PURPOSE : إنشاء رحلة جديدة — ride DB أولاً، primary DB ثانياً
// ═══════════════════════════════════════════════════════════════
include "../../connect.php";
try {
$con_ride = Database::get('ride');
} catch (Exception $e) {
error_log("[add_ride] Failed to connect to Ride Database: " . $e->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());
}