741 lines
36 KiB
PHP
741 lines
36 KiB
PHP
<?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());
|
||
} |