diff --git a/backend/functions.php b/backend/functions.php index 333813af..2a7a63e5 100644 --- a/backend/functions.php +++ b/backend/functions.php @@ -371,9 +371,17 @@ function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNa 'DriverList' => $payloadForDriver, 'order_id' => (string)$rideId ]; + + // 🆕 Destination Match Indicator + $title = "طلب جديد 🔔"; + if (!empty($driver['is_destination_match'])) { + $title = "في طريقك! 📍"; + $fcmData['is_destination_match'] = "1"; + } + $fcmResult = sendFcmNotification( $driverToken, - "طلب جديد 🔔", + $title, "هناك رحلة جديدة من " . $startNameLoc, $fcmData, "Order", diff --git a/backend/ride/location/save_driver_destination.php b/backend/ride/location/save_driver_destination.php index 123a7814..260ef2a3 100644 --- a/backend/ride/location/save_driver_destination.php +++ b/backend/ride/location/save_driver_destination.php @@ -52,15 +52,31 @@ function notifySocketServerDestination($userId, $hasDestination, $destLat = '', try { if ($action === 'get') { - $stmtGet = $con->prepare(" - SELECT target_latitude, target_longitude, destination_name, created_at - FROM driver_destinations - WHERE driver_id = :did - AND is_active = 1 - LIMIT 1 - "); - $stmtGet->execute([':did' => $user_id]); - $activeDest = $stmtGet->fetch(PDO::FETCH_ASSOC); + // 🟥 Read from Redis FIRST + $activeDest = null; + if (isset($redis)) { + try { + $cached = $redis->get("driver:destination:{$user_id}"); + if ($cached) { + $activeDest = json_decode($cached, true); + } + } catch (Exception $e) { + error_log("[save_driver_destination] Redis GET error: " . $e->getMessage()); + } + } + + // 🟦 Fallback to SQL if Redis miss + if (!$activeDest) { + $stmtGet = $con->prepare(" + SELECT target_latitude, target_longitude, destination_name, created_at + FROM driver_destinations + WHERE driver_id = :did + AND is_active = 1 + LIMIT 1 + "); + $stmtGet->execute([':did' => $user_id]); + $activeDest = $stmtGet->fetch(PDO::FETCH_ASSOC); + } if ($activeDest) { jsonSuccess($activeDest, "Active destination retrieved."); @@ -71,6 +87,18 @@ try { } if ($action === 'clear') { + // 🟥 Clear from Redis FIRST + if (isset($redis)) { + try { + $redis->del("driver:destination:{$user_id}"); + // Remove from the Geo Index (used by GEORADIUS search in pricing engine) + $redis->zRem("geo:driver:destinations", (string)$user_id); + } catch (Exception $e) { + error_log("[save_driver_destination] Redis DEL error: " . $e->getMessage()); + } + } + + // 🟦 Then clear from SQL $stmtDeactivate = $con->prepare(" UPDATE driver_destinations SET is_active = 0 @@ -79,7 +107,7 @@ try { "); $stmtDeactivate->execute([':did' => $user_id]); - // Sync with Redis on Socket Server + // Sync with Socket Server notifySocketServerDestination($user_id, 0); jsonSuccess(null, "تم إلغاء تفعيل الوجهة الشخصية بنجاح."); @@ -130,7 +158,36 @@ try { "); $stmtDeactivate->execute([':did' => $user_id]); - // 5. Insert new destination + // 🟥 5a. Write to Redis FIRST (primary read path for pricing engine) + if (isset($redis)) { + try { + $destData = [ + 'driver_id' => $user_id, + 'target_latitude' => (float)$destLat, + 'target_longitude' => (float)$destLng, + 'destination_name' => $destName, + 'is_active' => 1, + 'usage_date' => date('Y-m-d') + ]; + // TTL: end of day (midnight) + $secondsUntilMidnight = strtotime('tomorrow') - time(); + + // A) Store details as JSON string (for reading driver info) + $redis->setex("driver:destination:{$user_id}", $secondsUntilMidnight, json_encode($destData)); + + // B) Add to Geo Index for ultra-fast GEORADIUS proximity search + // GEOADD key longitude latitude member + $redis->geoAdd("geo:driver:destinations", (float)$destLng, (float)$destLat, (string)$user_id); + // Geo index has no native TTL per-member, so we set TTL on the whole key if not already set + if ($redis->ttl("geo:driver:destinations") < 0) { + $redis->expire("geo:driver:destinations", $secondsUntilMidnight); + } + } catch (Exception $e) { + error_log("[save_driver_destination] Redis SET error: " . $e->getMessage()); + } + } + + // 🟦 5b. Insert into SQL as secondary (persistent store) $stmtInsert = $con->prepare(" INSERT INTO driver_destinations (driver_id, target_latitude, target_longitude, destination_name, is_active, usage_date) @@ -143,12 +200,12 @@ try { ':name' => $destName ]); - // Sync with Redis on Socket Server + // Sync with Socket Server notifySocketServerDestination($user_id, 1, $destLat, $destLng, $destName); // Increment local Redis counter if (isset($redis)) { - $redisKey = "driver:dest_count:" . $user_id; + $redisKey = "driver:dest_count:{$user_id}"; $redis->incr($redisKey); $redis->expire($redisKey, 86400); // 24 hours TTL } diff --git a/backend/ride/pricing/get.php b/backend/ride/pricing/get.php index f466dda2..ce68f516 100644 --- a/backend/ride/pricing/get.php +++ b/backend/ride/pricing/get.php @@ -449,6 +449,86 @@ if (!empty($passenger_id)) { } } +// 🆕 المطابقة الذكية للوجهة (Destination Matching) +// Architecture: GEORADIUS (O(log N+K)) → detail fetch → SQL fallback +$isDestinationMatch = false; +$matchedDriverId = 0; + +try { + $bestDriverId = 0; + $foundViaRedis = false; + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // 🟥 Step 1: Redis GEORADIUS — ultra-fast O(log N + K) search + // Finds all drivers whose destination is within 3.5 km of + // the passenger's drop-off point. No PHP math. No full scan. + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + if (isset($redis)) { + try { + // geoRadius(key, longitude, latitude, radius, unit, [options]) + $nearbyDriverIds = $redis->geoRadius( + 'geo:driver:destinations', + (float)$destLng, + (float)$destLat, + 3.5, + 'km', + ['COUNT' => 1, 'ASC'] // closest first, stop after 1 + ); + + if (!empty($nearbyDriverIds)) { + $candidateId = (string)$nearbyDriverIds[0]; + // Verify destination is still active today (read detail JSON) + $detailJson = $redis->get("driver:destination:{$candidateId}"); + $detail = $detailJson ? json_decode($detailJson, true) : null; + + if ($detail + && !empty($detail['is_active']) + && ($detail['usage_date'] ?? '') === date('Y-m-d') + ) { + $bestDriverId = $candidateId; + $foundViaRedis = true; + } else { + // Stale entry — clean up from geo index + $redis->zRem('geo:driver:destinations', $candidateId); + } + } + } catch (Exception $e) { + error_log("[Destination Matching] Redis GEORADIUS error: " . $e->getMessage()); + } + } + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // 🟦 Step 2: SQL Fallback (only if Redis unavailable or empty) + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + if (!$foundViaRedis) { + $stmtDest = $con->prepare(" + SELECT driver_id, + ( 6371 * acos( cos( radians(:endLat) ) * cos( radians( target_latitude ) ) + * cos( radians( target_longitude ) - radians(:endLng) ) + sin( radians(:endLat) ) + * sin( radians( target_latitude ) ) ) ) AS dest_distance + FROM driver_destinations + WHERE is_active = 1 AND usage_date = CURDATE() + HAVING dest_distance <= 3.5 + ORDER BY dest_distance ASC + LIMIT 1 + "); + $stmtDest->execute([':endLat' => $destLat, ':endLng' => $destLng]); + $destMatch = $stmtDest->fetch(PDO::FETCH_ASSOC); + if ($destMatch) { + $bestDriverId = $destMatch['driver_id']; + } + } + + if ($bestDriverId) { + $isDestinationMatch = true; + $matchedDriverId = $bestDriverId; + // Passenger gets 14% discount for Destination Match + $discount = max((float)$discount, 14.0); + } +} catch (Exception $e) { + error_log("[Destination Matching] Error: " . $e->getMessage()); +} + // Calculate prices for all categories foreach ($categories as $key => $carType) { $result = calculateDynamicPrice($country, $minFare, $distance, $duration, $kazanRow, $startNameAddress, $endNameAddress, $destLat, $destLng, $passengerLat, $passengerLng, $carType); @@ -497,6 +577,8 @@ if (isset($encryptionHelper)) { // ✅ FIX R6: تضمين distance و duration في الـ token لمنع التلاعب 'distance' => $distance, 'duration' => $duration, + 'is_destination_match' => $isDestinationMatch ? 1 : 0, + 'matched_driver_id' => $matchedDriverId, 'expires' => time() + 420, // Valid for 7 minutes 'prices' => $pricesRaw ]; diff --git a/backend/ride/rides/add_ride.php b/backend/ride/rides/add_ride.php index 8d39c5e9..abab843a 100644 --- a/backend/ride/rides/add_ride.php +++ b/backend/ride/rides/add_ride.php @@ -160,6 +160,10 @@ $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; + // ── 2. تنسيق التواريخ ───────────────────────────────────────── $date_formatted = date("Y-m-d"); $time_formatted = date("H:i:s"); @@ -191,16 +195,17 @@ $insertData = [ ':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`) + `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)"; + :price_for_driver,:price_for_passenger,:distance,:is_destination_match)"; try { // ═══════════════════════════════════════════════════════════ @@ -224,11 +229,11 @@ try { $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`) + `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)"; + :price_for_driver,:price_for_passenger,:distance,:is_destination_match)"; try { $primaryData = $insertData; @@ -285,6 +290,36 @@ try { // Direct dispatch للسائقين القريبين $driversData = findBestDrivers($con, $startLat, $startLng, $carType, $endLat, $endLng); + + // 🆕 Destination Matching Priority + if ($is_destination_match && $matched_driver_id) { + $found = false; + foreach ($driversData as $k => $d) { + if ($d['captain_id'] == $matched_driver_id) { + // Remove from current position and move to the very front + unset($driversData[$k]); + $d['is_destination_match'] = 1; + array_unshift($driversData, $d); + $found = true; + break; + } + } + + // If driver wasn't nearby but we still want to give it to them because it's their route + if (!$found) { + $stmtD = $con->prepare("SELECT token FROM driverToken WHERE captain_id = :d"); + $stmtD->execute([':d' => $matched_driver_id]); + $dT = $stmtD->fetchColumn(); + if ($dT) { + array_unshift($driversData, [ + 'captain_id' => $matched_driver_id, + 'token' => $dT, + 'is_destination_match' => 1 + ]); + } + } + } + if (!empty($driversData)) { dispatchRideToDrivers($driversData, $insertedId, $payload, $start_name_loc, $encryptionHelper); error_log("[add_ride] Dispatched RideID=$insertedId to " . count($driversData) . " drivers."); diff --git a/backend/schema_primary.sql b/backend/schema_primary.sql index 2fa82ef8..de236db3 100644 --- a/backend/schema_primary.sql +++ b/backend/schema_primary.sql @@ -1458,6 +1458,7 @@ CREATE TABLE `ride` ( `carType` varchar(20) NOT NULL DEFAULT 'Speed', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_destination_match` tinyint(1) NOT NULL DEFAULT 0, `DriverIsGoingToPassenger` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `rideTimeStart` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `rideTimeFinish` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/backend/schema_ride.sql b/backend/schema_ride.sql index 6057379e..df08bd33 100644 --- a/backend/schema_ride.sql +++ b/backend/schema_ride.sql @@ -1361,6 +1361,7 @@ CREATE TABLE `ride` ( `carType` varchar(20) NOT NULL DEFAULT 'Speed', `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_destination_match` tinyint(1) NOT NULL DEFAULT 0, `DriverIsGoingToPassenger` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `rideTimeStart` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `rideTimeFinish` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/siro_driver/lib/controller/firebase/firbase_messge.dart b/siro_driver/lib/controller/firebase/firbase_messge.dart index b1f87fda..461b786e 100755 --- a/siro_driver/lib/controller/firebase/firbase_messge.dart +++ b/siro_driver/lib/controller/firebase/firbase_messge.dart @@ -157,7 +157,8 @@ class FirebaseMessagesController extends GetxController { Get.toNamed('/OrderRequestPage', arguments: { 'myListString': myListString, 'DriverList': myList, - 'body': body + 'body': body, + 'is_destination_match': message.data['is_destination_match'] ?? '0', }); } break; diff --git a/siro_driver/lib/controller/functions/location_controller.dart b/siro_driver/lib/controller/functions/location_controller.dart index 72e0556e..6544954d 100755 --- a/siro_driver/lib/controller/functions/location_controller.dart +++ b/siro_driver/lib/controller/functions/location_controller.dart @@ -405,7 +405,8 @@ class LocationController extends GetxController with WidgetsBindingObserver { Get.toNamed('/OrderRequestPage', arguments: { 'myListString': jsonEncode(driverList), 'DriverList': driverList, - 'body': 'New Trip Request via Socket ⚡' + 'body': 'New Trip Request via Socket ⚡', + 'is_destination_match': rideData['is_destination_match'] ?? '0', }); } else { Log.print( diff --git a/siro_driver/lib/controller/home/captin/order_request_controller.dart b/siro_driver/lib/controller/home/captin/order_request_controller.dart index 959f2a57..fb699446 100755 --- a/siro_driver/lib/controller/home/captin/order_request_controller.dart +++ b/siro_driver/lib/controller/home/captin/order_request_controller.dart @@ -63,6 +63,7 @@ class OrderRequestController extends GetxController String totalTripDistance = "--"; String totalTripDuration = "--"; String tripPrice = "--"; + bool isDestinationMatch = false; // 🆕 Destination Match flag String timeToPassenger = "Calculating...".tr; String distanceToPassenger = "--"; @@ -158,6 +159,12 @@ class OrderRequestController extends GetxController print("Error decoding DriverList: $e"); } } + // 🆕 Check for Destination Match flag from FCM data + if (args['is_destination_match'] == '1' || + args['is_destination_match'] == 1 || + args['is_destination_match'] == true) { + isDestinationMatch = true; + } } // ب) هل هي قادمة من Socket بالمفاتيح الرقمية ("0", "1", ...)؟ else {