diff --git a/backend/api/ride/get_competitor_context.php b/backend/api/ride/get_competitor_context.php index bb53edd1..b322098f 100644 --- a/backend/api/ride/get_competitor_context.php +++ b/backend/api/ride/get_competitor_context.php @@ -66,26 +66,20 @@ if ($avgPricePerKm <= 0) { } // Calculate the competitor's total price based on distance and average market per-km rate +// 🔥 لا يوجد أي تعديل صناعي على سعر المنافس هنا — الرقم المعروض للراكب +// يجب أن يعكس بيانات السوق الحقيقية فقط، حتى لو لم نكن أرخص فعلياً في هذه الرحلة. $competitorTotalPrice = round($distance * $avgPricePerKm, 2); -// Make sure competitor is slightly higher than us for psychological effect -// if their raw math somehow ended up lower due to straight per-km multiplication -if ($siroPrice > 0 && $competitorTotalPrice <= $siroPrice) { - // Force a dynamic difference based on country - $multiplier = ($countryCode === 'JO') ? 1.15 : 1.10; - $competitorTotalPrice = round($siroPrice * $multiplier, 2); -} - -// Calculate savings -$savingsPct = 0; -if ($competitorTotalPrice > 0 && $siroPrice < $competitorTotalPrice) { - $savingsPct = (($competitorTotalPrice - $siroPrice) / $competitorTotalPrice) * 100; -} - // Format the labels $compNameAr = 'التطبيقات الأخرى'; -$savingsLabel = "أوفر بـ " . number_format($savingsPct, 1) . "% من $compNameAr ⚡"; +// نعرض شارة "أوفر" فقط إذا كنا أرخص فعلياً حسب البيانات الحقيقية — لا تلاعب بالأرقام +$savingsPct = 0; +$savingsLabel = null; +if ($competitorTotalPrice > 0 && $siroPrice > 0 && $siroPrice < $competitorTotalPrice) { + $savingsPct = (($competitorTotalPrice - $siroPrice) / $competitorTotalPrice) * 100; + $savingsLabel = "أوفر بـ " . number_format($savingsPct, 1) . "% من $compNameAr ⚡"; +} $siroCommissionRate = 0.14; // Default 14% commission if ($countryCode === 'JO') $siroCommissionRate = 0.14; diff --git a/backend/core/bootstrap.php b/backend/core/bootstrap.php index 94950d13..a691ecab 100644 --- a/backend/core/bootstrap.php +++ b/backend/core/bootstrap.php @@ -119,7 +119,18 @@ try { // --- Location Server Redis --- $redisLocation = new Redis(); - $locHost = getenv('REDIS_LOCATION_HOST') ?: $redisHost; + // 🔥 [Fix Silent Fallback] إذا لم تُضبط REDIS_LOCATION_HOST صراحة، نسقط + // على Redis الرئيسي — وهذا يجعل استعلامات كثافة السائقين (geo:drivers:*) + // ترجع فارغة بصمت لأن تلك المفاتيح تُكتب فقط على Redis الخاص بلوكيشن + // سيرفر. نسجّل تحذيراً واضحاً حتى لا يمر هذا دون ملاحظة في اللوجز. + $locHostConfigured = getenv('REDIS_LOCATION_HOST'); + if (!$locHostConfigured) { + error_log('[REDIS] ⚠️ REDIS_LOCATION_HOST is not set — $redisLocation is falling back to the MAIN redis host (' . $redisHost . '). ' . + 'geo:drivers:available / driver:profile:* / driver:public:* keys live only on the location-server Redis, ' . + 'so driver-density lookups (getSpeed.php, heatmap_live.php, pricing/get.php) will silently return empty results ' . + 'unless REDIS_LOCATION_HOST/PORT/PASSWORD are configured correctly in .env.'); + } + $locHost = $locHostConfigured ?: $redisHost; $locPort = (int)(getenv('REDIS_LOCATION_PORT') ?: $redisPort); $locPass = getenv('REDIS_LOCATION_PASSWORD') ?: $redisPass; @@ -127,6 +138,7 @@ try { if ($locPass) $redisLocation->auth($locPass); // No prefix for location server } else { + error_log("[REDIS] ⚠️ Failed to connect \$redisLocation to $locHost:$locPort — driver-density features will be degraded."); $redisLocation = null; } } diff --git a/backend/driver_socket.php b/backend/driver_socket.php deleted file mode 100644 index 3b8659ca..00000000 --- a/backend/driver_socket.php +++ /dev/null @@ -1,553 +0,0 @@ -ping(); - return $redis; - } catch (\Exception $e) { - logMsg('⚠️ Redis ping failed, reconnecting...'); - $redis = null; - } - } - - try { - $client = new RedisClient([ - 'scheme' => 'tcp', - 'host' => '127.0.0.1', - 'port' => 6379, - 'password' => $redisPass, - 'read_write_timeout' => 0, - ]); - $client->connect(); - $redis = $client; - return $redis; - } catch (\Exception $e) { - logMsg('❌ Redis Error: ' . $e->getMessage()); - return null; - } -} - -// ============================================================ -// 📐 Haversine Distance (متر) -// ============================================================ -function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float { - $R = 6371000; - $dLat = deg2rad($lat2 - $lat1); - $dLng = deg2rad($lng2 - $lng1); - $a = sin($dLat / 2) ** 2 - + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2; - return $R * 2 * atan2(sqrt($a), sqrt(1 - $a)); -} - -// ============================================================ -// 📡 Forward موقع السائق → سيرفر الراكب (ASYNC) -// ============================================================ -function forwardLocationToPassengerSocket( - string $driverId, - string $passengerId, - array $payload, - string $internalKey, - array &$fwdThrottle -): void { - if (empty($passengerId)) return; - - $now = time(); - $last = $fwdThrottle[$driverId] ?? null; - - if ($last !== null) { - $timeDiff = $now - $last['ts']; - $dist = haversineDistance( - $last['lat'], $last['lng'], - (float)$payload['lat'], (float)$payload['lng'] - ); - if ($dist < FORWARD_MIN_METERS && $timeDiff < FORWARD_MAX_SECONDS) return; - } - - $fwdThrottle[$driverId] = [ - 'ts' => $now, - 'lat' => (float)$payload['lat'], - 'lng' => (float)$payload['lng'], - ]; - - $http = new AsyncHttp(); - $http->request( - 'http://127.0.0.1:3031', - [ - 'method' => 'POST', - 'data' => http_build_query([ - 'action' => 'update_driver_location', - 'passenger_id' => $passengerId, - 'payload' => json_encode($payload), - ]), - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - 'x-internal-key' => $internalKey, - 'Connection' => 'close', - ], - 'timeout' => 3, - ], - null, - fn(\Exception $e) => logMsg('⚠️ Forward failed: ' . $e->getMessage()) - ); -} - -// ============================================================ -// 📲 FCM (ASYNC) -// ============================================================ -function sendFCM_Async(string $token, string $title, string $body, array $rideData): void { - if (empty($token)) return; - - $http = new AsyncHttp(); - $http->request( - 'https://api.intaleq.xyz/siro/ride/firebase/send_fcm.php', - [ - 'method' => 'POST', - 'data' => json_encode([ - 'target' => $token, - 'title' => $title, - 'body' => $body, - 'isTopic' => false, - 'category' => 'Order', - 'tone' => 'start', - 'passengerList' => json_encode($rideData), - ]), - 'headers' => ['Content-Type' => 'application/json; charset=UTF-8'], - 'timeout' => 5, - ], - null, - fn(\Exception $e) => logMsg('⚠️ FCM failed: ' . $e->getMessage()) - ); -} - -// ============================================================ -// 🧠 Memory State & Event Buffer -// ============================================================ -$connectedDrivers = []; -$active_orders_drivers = []; -$driverState = []; -$fwdThrottle = []; -$eventBuffer = []; // 🚀 Level 2: مصفوفة تجميع الأحداث لـ Redis - -// ============================================================ -// 🚀 Socket.IO — بورت 2020 -// ============================================================ -$io = new SocketIO(2020); - -// ============================================================ -// A. Internal HTTP Server & Redis Batch Processor (Worker Start) -// ============================================================ -$io->on('workerStart', function () use ($io, $INTERNAL_KEY) { - - // 🚀 1. Redis Pipeline Batch Processor (Level 2) - // يعمل كل نصف ثانية، يجمع كل الأوامر ويرسلها لـ Redis دفعة واحدة - Timer::add(REDIS_BATCH_INTERVAL, function() { - global $eventBuffer; - if (empty($eventBuffer)) return; - - $redis = getRedis(); - if (!$redis) return; - - try { - $pipe = $redis->pipeline(); - $processedCount = 0; - - foreach ($eventBuffer as $driverId => $ops) { - $profileKey = "driver:profile:$driverId"; - $processedCount++; - - if (isset($ops['hmset'])) { - $pipe->hmset($profileKey, $ops['hmset']); - } - if (isset($ops['expire'])) { - $pipe->expire($profileKey, $ops['expire']); - } - if (isset($ops['status_change'])) { - $oldStatus = $ops['status_change']['old']; - $newStatus = $ops['status_change']['new']; - - if ($oldStatus === 'on') $pipe->zrem('geo:drivers:busy', $driverId); - if ($oldStatus === 'off') $pipe->zrem('geo:drivers:available', $driverId); - - if ($newStatus === 'close' || $newStatus === 'blocked') { - $pipe->zrem('geo:drivers:available', $driverId); - $pipe->zrem('geo:drivers:busy', $driverId); - } - } - if (isset($ops['geoadd'])) { - $st = $ops['geoadd']['status']; - $lng = $ops['geoadd']['lng']; - $lat = $ops['geoadd']['lat']; - - if ($st === 'off') { - $pipe->geoadd('geo:drivers:available', $lng, $lat, $driverId); - } elseif ($st === 'on') { - $pipe->geoadd('geo:drivers:busy', $lng, $lat, $driverId); - } - } - } - - $pipe->execute(); - $eventBuffer = []; // إفراغ المصفوفة بعد التنفيذ الناجح - // logMsg("⚡ Processed Redis Batch: $processedCount drivers updated in 1 network call."); - - } catch (\Exception $e) { - logMsg("⚠️ Redis Pipeline Error: " . $e->getMessage()); - } - }); - - // 🌐 2. Internal HTTP Server — بورت 2021 - $innerHttp = new Worker('http://0.0.0.0:2021'); - - $innerHttp->onMessage = function ($connection, $request) use ($io, $INTERNAL_KEY) { - global $active_orders_drivers, $connectedDrivers; - - $headers = $request->header(); - if (($headers['x-internal-key'] ?? '') !== $INTERNAL_KEY) { - $connection->send('Unauthorized'); - return; - } - - $post = $request->post(); - $action = trim($post['action'] ?? ''); - $redis = getRedis(); - - // ── 1. Dispatch Order ──────────────────────────────── - if ($action === 'dispatch_order') { - $rideId = $post['ride_id'] ?? null; - $drivers = json_decode($post['drivers_ids'] ?? '[]', true); - $payload = $post['payload'] ?? []; - if (is_array($payload)) $payload = array_values($payload); - - if ($rideId && !empty($drivers)) { - $active_orders_drivers[$rideId] = $drivers; - logMsg("🚀 Dispatch Ride #$rideId → " . count($drivers) . ' drivers.'); - } - - foreach ($drivers as $driverId) { - if (!isset($connectedDrivers[$driverId])) continue; - $io->to('driver_' . $driverId)->emit('new_ride_request', $payload); - - $platform = $connectedDrivers[$driverId]['platform'] ?? 'android'; - $token = $connectedDrivers[$driverId]['token'] ?? ''; - if (!empty($token)) { - sendFCM_Async($token, 'طلب جديد', 'لديك رحلة جديدة قريبة منك', $payload); - } - } - $connection->send('Dispatched'); - - // ── 2. Market New Ride ──────────────────────────────── - } elseif ($action === 'market_new_ride') { - $payload = $post['payload'] ?? []; - $rideId = $payload['id'] ?? null; - $lat = (float)($payload['start_lat'] ?? 0); - $lng = (float)($payload['start_lng'] ?? 0); - - if (!$redis || !$rideId || $lat == 0 || $lng == 0) { - $connection->send('Error: Redis unavailable or invalid coords'); - return; - } - - $redis->geoadd('geo:rides:waiting', $lng, $lat, $rideId); - $nearbyDrivers = $redis->georadius('geo:drivers:available', $lng, $lat, 50, 'km'); - - $count = 0; - foreach ($nearbyDrivers as $driverId) { - if (isset($connectedDrivers[$driverId])) { - $io->to('driver_' . $driverId)->emit('market_new_ride', $payload); - $count++; - } - } - logMsg("📢 Market Ride #$rideId → $count drivers."); - $connection->send("Broadcasted to $count drivers"); - - // ── 3. Get Nearby Ride IDs ──────────────────────────── - } elseif ($action === 'get_nearby_ride_ids') { - $lat = (float)($post['lat'] ?? 0); - $lng = (float)($post['lng'] ?? 0); - $radius = (float)($post['radius'] ?? 9); - - if (!$redis) { $connection->send(json_encode([])); return; } - - $results = $redis->georadius( - 'geo:rides:waiting', $lng, $lat, $radius, 'km', - ['WITHDIST' => true, 'SORT' => 'ASC', 'COUNT' => 40] - ); - $connection->send(json_encode($results)); - - // ── 4. Ride Taken ───────────────────────────────────── - } elseif ($action === 'ride_taken_event') { - $rideId = $post['ride_id'] ?? null; - $winnerDriverId = $post['taken_by_driver_id'] ?? null; - - if (!$rideId) { $connection->send('Error: Missing ride_id'); return; } - - if ($redis) $redis->zrem('geo:rides:waiting', $rideId); - - $io->emit('ride_taken', [ - 'ride_id' => $rideId, - 'taken_by_driver_id' => $winnerDriverId, - ]); - - unset($active_orders_drivers[$rideId]); - logMsg("✅ Ride #$rideId taken by #$winnerDriverId."); - $connection->send('OK'); - - // ── 5. Force Disconnect ─────────────────────────────── - } elseif ($action === 'force_disconnect') { - $driverId = $post['driver_id'] ?? null; - - if ($driverId && isset($connectedDrivers[$driverId])) { - $connectedDrivers[$driverId]['conn']->disconnect(); - unset($connectedDrivers[$driverId]); - - if ($redis) { - $redis->zrem('geo:drivers:available', $driverId); - $redis->zrem('geo:drivers:busy', $driverId); - } - logMsg("🚫 Driver #$driverId force-disconnected."); - $connection->send('Disconnected'); - } else { - $connection->send('Driver not connected'); - } - - } else { - $connection->send('Unknown action'); - } - }; - - $innerHttp->listen(); -}); - -// ============================================================ -// B. WebSocket Events للسائقين -// ============================================================ -$io->on('connection', function ($socket) use ($INTERNAL_KEY) { - global $connectedDrivers, $driverState, $fwdThrottle, $eventBuffer; - - $query = $socket->handshake['query'] ?? []; - $driverId = $query['driver_id'] ?? null; - $platform = $query['platform'] ?? 'android'; - $token = $query['token'] ?? ''; - - if (!$driverId) { - $socket->disconnect(); - return; - } - - $socket->join('driver_' . $driverId); - $connectedDrivers[$driverId] = [ - 'conn' => $socket, - 'platform' => $platform, - 'token' => $token, - ]; - - if (!isset($driverState[$driverId])) { - $driverState[$driverId] = [ - 'lat' => 0.0, - 'lng' => 0.0, - 'speed' => -999.0, - 'heading' => -999.0, - 'status' => '', - 'expire_ts' => 0, - ]; - } - - logMsg("✅ Driver Connected: #$driverId ($platform)"); - - $socket->on('ping_alive', function () { - // Socket.IO handles pong automatically - }); - - $socket->on('update_location', function ($data) - use ($driverId, $INTERNAL_KEY, &$driverState, &$fwdThrottle, &$eventBuffer) - { - global $connectedDrivers; - - $data = (array) $data; - - $lat = isset($data['lat']) ? (float)$data['lat'] : null; - $lng = isset($data['lng']) ? (float)$data['lng'] : null; - $heading = (float)($data['heading'] ?? 0); - $speed = (float)($data['speed'] ?? 0); - $status = (string)($data['status'] ?? 'off'); - $distance = (float)($data['distance'] ?? 0); - $passengerId = (string)($data['passenger_id'] ?? ''); - $rideId = $data['ride_id'] ?? null; - - if ($lat === null || $lng === null) return; - - $state = &$driverState[$driverId]; - $now = time(); - - // 1. Forward للراكب (ASYNC + throttle) - if (!empty($passengerId)) { - forwardLocationToPassengerSocket( - $driverId, $passengerId, - [ - 'latitude' => $lat, - 'longitude' => $lng, - 'heading' => $heading, - 'speed' => $speed, - 'ride_id' => $rideId, - 'driver_id' => $driverId, - ], - $INTERNAL_KEY, $fwdThrottle - ); - } - - // 2. حساب ماذا تغيّر لتجنب ضغط Redis - $movedMeters = ($state['lat'] == 0.0 && $state['lng'] == 0.0) - ? 999.0 - : haversineDistance($state['lat'], $state['lng'], $lat, $lng); - - $didMove = $movedMeters >= MIN_MOVE_METERS; - $speedMs = $speed / 3.6; - $speedChanged = abs($speedMs - $state['speed']) >= HMSET_SPEED_DELTA; - $headingChanged = abs($heading - $state['heading']) >= HMSET_HEADING_DELTA; - $statusChanged = ($status !== $state['status']); - - $needHmset = $speedChanged || $headingChanged || $statusChanged; - $needGeoadd = $didMove; - $needExpireRefresh = ($now - $state['expire_ts']) >= EXPIRE_REFRESH_SECONDS; - - if (!$needHmset && (!$needGeoadd && !$statusChanged) && !$needExpireRefresh) { - return; // لم يتغير شيء مهم، تجاهل تماماً (0 عمليات Redis) - } - - // 🚀 3. Buffering Event بدل الإرسال المباشر لـ Redis (Level 2 Magic) - if (!isset($eventBuffer[$driverId])) { - $eventBuffer[$driverId] = []; - } - - if ($needHmset) { - $eventBuffer[$driverId]['hmset'] = [ - 'id' => $driverId, 'heading' => $heading, 'speed' => $speed, 'status' => $status, 'updated_at' => $now - ]; - $state['speed'] = $speedMs; - $state['heading'] = $heading; - } - - if ($needExpireRefresh || $needHmset) { - $eventBuffer[$driverId]['expire'] = 900; - $state['expire_ts'] = $now; - } - - if ($statusChanged) { - $eventBuffer[$driverId]['status_change'] = [ - 'old' => $state['status'], - 'new' => $status - ]; - $state['status'] = $status; - - // Auto disconnect if blocked - if ($status === 'blocked') { - if (isset($connectedDrivers[$driverId])) { - $connectedDrivers[$driverId]['conn']->disconnect(); - unset($connectedDrivers[$driverId]); - } - } - } - - if ($needGeoadd || $statusChanged) { - $eventBuffer[$driverId]['geoadd'] = [ - 'status' => $status, - 'lng' => $lng, - 'lat' => $lat - ]; - if ($needGeoadd) { - $state['lat'] = $lat; - $state['lng'] = $lng; - } - } - }); - - $socket->on('disconnect', function () use ($driverId) { - global $connectedDrivers, $driverState, $fwdThrottle; - - unset($connectedDrivers[$driverId]); - unset($driverState[$driverId]); - unset($fwdThrottle[$driverId]); - - logMsg("❌ Driver Disconnected: #$driverId"); - }); -}); - -Worker::runAll(); \ No newline at end of file diff --git a/backend/functions.php b/backend/functions.php index 7f23372f..433c9e94 100644 --- a/backend/functions.php +++ b/backend/functions.php @@ -79,7 +79,10 @@ function sendToLocationServer($action, $data) { function findBestDrivers($con, $lat, $lng, $carType, $endLat = null, $endLng = null) { // 1. الاتصال بـ Redis لجلب الأقرب - $locationServerUrl = "https://location.intaleq.xyz/api_get_nearby.php"; + // 🔥 [Fix Hardcoded URL] كان مثبتاً على رابط الإنتاج مباشرة بخلاف كل استدعاء + // آخر لسيرفر اللوكيشن في هذا الملف (يعتمد على env)، فأي بيئة غير إنتاج كانت + // تضرب سيرفر الإنتاج الحقيقي بالخطأ. + $locationServerUrl = getenv('LOCATION_API_URL') ?: "https://location.intaleq.xyz/api_get_nearby.php"; $INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : ''; $postData = ['lat' => $lat, 'lng' => $lng, 'radius' => 5, 'limit' => 100]; diff --git a/backend/ride/rides/cancel_ride_by_passenger.php b/backend/ride/rides/cancel_ride_by_passenger.php index 0c779ca5..458ffde2 100644 --- a/backend/ride/rides/cancel_ride_by_passenger.php +++ b/backend/ride/rides/cancel_ride_by_passenger.php @@ -77,31 +77,32 @@ try { } // ================================================================= - // 2. إشعار السائق (Socket + FCM) + // 2. إشعار السائق/السائقين (Socket + FCM) // ================================================================= + // 🔥 يُرسل دائماً بغض النظر عن driver_id — إذا كانت الرحلة لم تُقبل بعد + // (driver_id = 0)، لوكيشن سيرفر يستخدم ride:offered_drivers:{rideId} + // من Redis لإشعار كل السائقين الذين وصلهم عرض هذه الرحلة أصلاً. + $socketUrl = getenv('LOCATION_SERVER_URL') ?: 'http://location.intaleq.xyz:2021'; + $internalKeyPath = getenv('INTERNAL_SOCKET_KEY_PATH') ?: ''; + $internalKey = ($internalKeyPath && file_exists($internalKeyPath)) ? trim(file_get_contents($internalKeyPath)) : (getenv('INTERNAL_SOCKET_KEY') ?: ''); + + $ch = curl_init($socketUrl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ + 'action' => 'cancel_ride', + 'driver_id' => $driverId, + 'ride_id' => $rideId, + 'reason' => $reason + ])); + if (!empty($internalKey)) curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-internal-key: $internalKey"]); + curl_setopt($ch, CURLOPT_TIMEOUT_MS, 500); + curl_setopt($ch, CURLOPT_NOSIGNAL, 1); + @curl_exec($ch); + curl_close($ch); + if ($driverId > 0) { - - // أ) Socket (إشعار السائق في التطبيق فوراً) - $socketUrl = getenv('LOCATION_SERVER_URL') ?: 'http://location.intaleq.xyz:2021'; - $internalKeyPath = getenv('INTERNAL_SOCKET_KEY_PATH') ?: ''; - $internalKey = ($internalKeyPath && file_exists($internalKeyPath)) ? trim(file_get_contents($internalKeyPath)) : (getenv('INTERNAL_SOCKET_KEY') ?: ''); - - $ch = curl_init($socketUrl); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([ - 'action' => 'cancel_ride', - 'driver_id' => $driverId, - 'ride_id' => $rideId, - 'reason' => $reason - ])); - if (!empty($internalKey)) curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-internal-key: $internalKey"]); - curl_setopt($ch, CURLOPT_TIMEOUT_MS, 500); - curl_setopt($ch, CURLOPT_NOSIGNAL, 1); - @curl_exec($ch); - curl_close($ch); - - // ب) FCM (باستخدام الدالة الجديدة مع فك التشفير) + // FCM للسائق الذي قَبِل الرحلة فعلاً (باستخدام الدالة الجديدة مع فك التشفير) $driverToken = filterRequest("driver_token"); if (empty($driverToken)) { diff --git a/backend/ride/rides/cron_ride_timeout.php b/backend/ride/rides/cron_ride_timeout.php index d2c04376..ff8ce9a2 100644 --- a/backend/ride/rides/cron_ride_timeout.php +++ b/backend/ride/rides/cron_ride_timeout.php @@ -1,53 +1,85 @@ prepare($sqlSelect); + $stmtSelect->execute(); + $staleRides = $stmtSelect->fetchAll(PDO::FETCH_ASSOC); - $stmtUpdate = $con->prepare($sqlUpdate); - $stmtUpdate->execute(); - $updatedCount = $stmtUpdate->rowCount(); + $updatedCount = 0; + + if (!empty($staleRides)) { + $sqlUpdate = "UPDATE ride SET status = 'timeout', updated_at = NOW() WHERE id = ? AND status = 'waiting'"; + $stmtUpdate = $con->prepare($sqlUpdate); + + // نسخة أرشيفية (best-effort) + $con_ride = null; + try { + $con_ride = Database::get('ride'); + } catch (Exception $e) { + error_log("[cron_ride_timeout] Secondary ride DB unavailable: " . $e->getMessage()); + } + $stmtUpdate2 = $con_ride ? $con_ride->prepare( + "UPDATE ride SET status = 'timeout', updated_at = NOW() WHERE id = ? AND status = 'waiting'" + ) : null; + + foreach ($staleRides as $ride) { + $rideId = $ride['id']; + + $stmtUpdate->execute([$rideId]); + $updatedCount += $stmtUpdate->rowCount(); + + if ($stmtUpdate2) { + try { + $stmtUpdate2->execute([$rideId]); + } catch (PDOException $e) { + error_log("[cron_ride_timeout] Secondary DB update failed for #$rideId: " . $e->getMessage()); + } + } + + // 🆕 تنظيف Redis + إعلام أي سائقين ما زالوا يرون هذا الطلب على أنه ملغى + sendToLocationServer('cancel_ride', [ + 'ride_id' => $rideId, + 'driver_id' => 0, + 'reason' => 'timeout', + ]); + } + } // ========================================================= - // الخطوة 2: الحذف من جدول الانتظار (تنظيف Hot Data) + // الخطوة 2: تنظيف جدول waitingRides القديم (إن وُجدت صفوف فيه من مسار قديم) // ========================================================= - - $sqlDelete = "DELETE FROM waitingRides - WHERE created_at < DATE_SUB(NOW(), INTERVAL $minutesLimit MINUTE)"; + $deletedCount = 0; + try { + $sqlDelete = "DELETE FROM waitingRides WHERE created_at < DATE_SUB(NOW(), INTERVAL $minutesLimit MINUTE)"; + $stmtDelete = $con->prepare($sqlDelete); + $stmtDelete->execute(); + $deletedCount = $stmtDelete->rowCount(); + } catch (PDOException $e) { + error_log("[cron_ride_timeout] waitingRides cleanup skipped: " . $e->getMessage()); + } - $stmtDelete = $con->prepare($sqlDelete); - $stmtDelete->execute(); - $deletedCount = $stmtDelete->rowCount(); - - // ========================================================= - // الخطوة 3: (اختياري) تنظيف الريدز - // ========================================================= - // بما أنك تستخدم Redis، المفترض أن تحذفها منه أيضاً. - // لكن بما أن الريدز يعتمد على TTL (Expire) أو سيتم تحديثه عند الطلب القادم، - // فالحذف من الـ MySQL يكفي لأن getRideWaiting سيفحص MySQL ولن يجدها. - - // تقرير العملية - if ($deletedCount > 0) { - $msg = "✅ [Cleanup Cron] Success: Timed out $updatedCount rides in Main DB, and Deleted $deletedCount rides from Waiting DB."; + if ($updatedCount > 0 || $deletedCount > 0) { + $msg = "✅ [Cleanup Cron] Success: Timed out $updatedCount ride(s) in ride table, and cleaned $deletedCount legacy waitingRides row(s)."; error_log($msg); echo json_encode(["status" => "success", "message" => $msg]); } else { @@ -59,7 +91,6 @@ try { } catch (PDOException $e) { $errorMsg = "❌ [Cleanup Cron] Error: " . $e->getMessage(); error_log($errorMsg); - error_log("[cron_ride_timeout] Error: " . $e->getMessage()); echo json_encode(["status" => "failure", "message" => "An internal error occurred."]); } -?> \ No newline at end of file +?> diff --git a/documents/marketing_and_growth_strategy.md b/documents/marketing_and_growth_strategy.md new file mode 100644 index 00000000..3f4b6997 --- /dev/null +++ b/documents/marketing_and_growth_strategy.md @@ -0,0 +1,89 @@ +
+

استراتيجيات النمو والتسويق (Growth & Marketing Strategies)

+ +

1. إعادة تأطير المشكلة: السيولة والثقة

+المشكلة الحقيقية في التطبيقات الجديدة ليست "الوعي بالعلامة التجارية" ولا كثرة المنشورات الإعلانية، بل هي السيولة والثقة: +
+- الركاب: يحتاجون لسيارة قريبة ومتاحة فوراً. السعر الأرخص لا قيمة له إذا لم يجد الراكب سيارة عند فتح التطبيق. +
+- السائقون: يحتاجون لطلبات مستمرة. إذا لم يجدوا طلبات، سيطفئون التطبيق ويعودون للمنافسين. +
+لذلك، الهدف ليس الانتشار الجغرافي الواسع في البداية، بل الكثافة العالية في منطقة محددة ووقت محدد. + +
+ +

2. القاعدة الذهبية: كثافة لا انتشار

+بدلاً من محاولة تغطية مدينة كاملة بميزانية محدودة: +
+- السيطرة المحلية: اختر منطقة واحدة (مثل محيط جامعة، أو حي مزدحم) وركز كل السائقين والعروض فيها حتى يصبح وقت الوصول أقل من 4 دقائق. +
+- الكثافة العالية تؤدي إلى: وصول سريع ← تجربة ممتازة للراكب ← حديث الناس الإيجابي بين بعضهم ← نمو مجاني. +
+- التوسع يتم تدريجياً لحي مجاور بعد السيطرة التامة على الحي الأول. الانتشار المبكر قاتل للميزانيات المحدودة. + +
+ +

3. السائقون أولاً: توفير العرض هو الأساس

+الركاب يأتون تلقائياً إذا توفرت السيارات. يجب التركيز على: +
+- الاستغلال العالي: كلما زادت رحلات السائق في الساعة الواحدة، يمكنك خفض السعر للراكب مع زيادة دخل السائق الإجمالي في نفس الوقت. +
+- تلميح الأرباح: إبراز الأرباح الإضافية التي سيجنيها السائق في كل رحلة مقارنة بالتطبيقات الأخرى بوضوح. +
+- رسالة التوظيف: التركيز على "العمولة الأقل" والدفع السريع والمباشر. +
+- الدخل المضمون: توفير أوقات دخل مضمون خلال الساعات الميتة لإبقاء السائقين متصلين ومتاحين. +
+- مجتمع السائقين: إنشاء مجموعات حقيقية للسائقين (واتساب/تيليجرام) للاستماع لهم وحل مشاكلهم بسرعة، وتفعيل نظام إحالة السائقين لجلب زملائهم. +
+- أيام بدون عمولة: تخصيص أهدأ يوم في الأسبوع ليكون بدون عمولة تماماً لتشجيع توفر السيارات بكثافة. + +
+ +

4. الركاب: حلقات النمو الفعالة

+- إحالة ثنائية الجانب: مكافأة كلا الطرفين (الداعي والمدعو) برصيد رحلات، وتفعيل المكافأة فقط بعد اكتمال أول رحلة لتجنب التلاعب والغش. البنية التقنية لذلك جاهزة لديك. +
+- الاحتفاظ بالعملاء (الولاء): خصم كبير على أول رحلة، ثم إشعار تذكير للرحلة الثانية، وتفعيل نظام التلعيب والولاء للمحافظة على الراكب. +
+- استهداف المناسبات: التركيز على حملات دقيقة بأوقات مدروسة (رمضان وقت الإفطار، الأعياد، بداية الفصل الجامعي). هذه الحملات مردودها أعلى بكثير من الإعلان العام. +
+- شراكات محلية: التعاون مع المقاهي والمطاعم والجامعات، واعتبارها "نقاط التقاط" مدعومة بخصومات، بالإضافة للتعاون مع المؤثرين الصغار محلياً. + +
+ +

5. الثقة كسلاح ضد المنافسين القدامى

+لا تنافس الشركات العملاقة على "السمعة التاريخية"، بل ركز على نقاط ضعفها: +
+- السعر الثابت والشفاف: تقديم وعْد للعملاء بأنه "لا مفاجآت في السعر" (استقرار الأسعار)، على عكس تقلبات الأسعار والقفزات المزعجة عند المنافسين. +
+- الأمان المطلق: إبراز ميزات هامة للثقة، خاصة للنساء (مشاركة الرحلة، زر الطوارئ، خيار تفضيل سائقة، سائقون موثقون). +
+- الشفافية المؤسسية: فيديوهات حقيقية من مؤسس التطبيق يشرح فيها بشفافية كيف يتم تسعير الرحلات ولماذا التطبيق أرخص ومربح للسائق. الصدق هو أفضل وأقوى محتوى مقنع. +
+- الدفع النقدي السلس: تسهيل التعامل بالكاش بدون تعقيدات، حيث يعتبر الكاش الملك والمفضل في العديد من أسواقنا. + +
+ +

6. استراتيجية وسائل التواصل الاجتماعي السليمة

+- المحتوى الحقيقي: قصص النجاح الواقعية وردود وتجارب الركاب الفعليين هي الذهب الحقيقي الذي يجب الاستثمار فيه. +
+- الابتعاد عن التفاعلات الوهمية والدراما المفبركة: +
  1. المحتوى المزور يعرض حسابات التطبيق للحظر الجماعي من قبل المنصات. +
  2. إذا تم كشفها، سيكون الضرر على الثقة كارثياً ولا يمكن إصلاحه بسهولة، وسيستغلها المنافسون ضدك. +
  3. الإعجابات الوهمية لا تزيد من "السيولة" ولا تضع سيارة حقيقية قريبة من الراكب. +
+- البديل البناء: توجيه جهود الأتمتة لإدارة مجتمع حقيقية، الرد السريع على الاستفسارات، وتسليط الضوء على آراء المستخدمين الحقيقية الموثقة بإذنهم. + +
+ +

7. إشعار تحديث الأسعار الذكي للإدارة

+فكرة ممتازة تشغيلياً، ويجب أن تكون مبنية على "أحداث هامة" وليس إشعارات مستمرة لكي لا يتم تجاهلها: +
+- لا يتم الإرسال مع كل تحديث بسيط (ضجيج). +
+- متى يتم الإرسال؟ فقط عند تجاوز التغير نسبة مئوية معينة، أو عند تفعيل نافذة تسعير موسمي أو اكتشاف ذروة جديدة. +
+- محتوى الإشعار: الدولة/المنطقة، المحرك المسؤول عن التغيير (اكتشاف ذروة / موسمي / تعديل ثبات)، السعر قبل وبعد، والسبب. +
+- الخلاصة اليومية: تقديم إشعار تجميعي بنهاية اليوم للإدارة يلخص أهم تحركات السوق. +
diff --git a/loction_server/driver_socket.php b/loction_server/driver_socket.php index 498fead9..2f223e4b 100755 --- a/loction_server/driver_socket.php +++ b/loction_server/driver_socket.php @@ -440,6 +440,49 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) { logMsg("✅ Ride #$rideId taken by #$winnerDriverId."); $connection->send('OK'); + // ── 4b. Ride Cancelled (بالراكب أو بالسائق) ───────────── + } elseif ($action === 'cancel_ride') { + $rideId = $post['ride_id'] ?? null; + $driverId = $post['driver_id'] ?? null; + $reason = $post['reason'] ?? ''; + + if (!$rideId) { $connection->send('Error: Missing ride_id'); return; } + + // كل السائقين الذين وصلهم عرض هذه الرحلة (سواء قَبِلها أحد أم لا) + $offeredDrivers = []; + + if ($redis) { + $redis->zrem('geo:rides:waiting', $rideId); + + $offeredSetKey = "ride:offered_drivers:$rideId"; + $offeredDrivers = $redis->smembers($offeredSetKey); + foreach ($offeredDrivers as $dId) { + $redis->zrem("driver:pending_orders:$dId", $rideId); + } + $redis->del($offeredSetKey); + $redis->del("ride:offer:$rideId"); + } + + // نوحّد قائمة من يجب إشعارهم: السائق الحالي (إن وُجد) + كل من عُرضت عليهم الرحلة + $notifyDrivers = $offeredDrivers; + if ($driverId) $notifyDrivers[] = (string)$driverId; + $notifyDrivers = array_unique(array_filter($notifyDrivers, fn($d) => $d && $d !== '0')); + + $notified = 0; + foreach ($notifyDrivers as $dId) { + if (isset($connectedDrivers[$dId])) { + $io->to('driver_' . $dId)->emit('ride_cancelled', [ + 'ride_id' => $rideId, + 'reason' => $reason, + ]); + $notified++; + } + } + + unset($active_orders_drivers[$rideId]); + logMsg("🚫 Ride #$rideId cancelled — notified $notified/" . count($notifyDrivers) . " driver(s)."); + $connection->send('OK'); + // ── 7. Update Ride State (Redis Cache فقط — بدون Forward) } elseif ($action === 'update_ride_state') { $rideId = $post['ride_id'] ?? null; diff --git a/siro_driver/lib/controller/home/captin/map_driver_controller.dart b/siro_driver/lib/controller/home/captin/map_driver_controller.dart index 6dde41a4..9d3d3c79 100755 --- a/siro_driver/lib/controller/home/captin/map_driver_controller.dart +++ b/siro_driver/lib/controller/home/captin/map_driver_controller.dart @@ -2145,6 +2145,10 @@ class MapDriverController extends GetxController // تحديث فقط الجزء الخاص بمعلومات الراكب update(); + // 🔥 [Fix Polyline] مسح المسار الأصفر (سائق→راكب) قبل رسم الأزرق (راكب→وجهة) + // نفس علة إعادة استخدام نفس المعرف بلون جديد الموجودة في انتقال بدء الرحلة أعلاه + clearPolyline(); + // رسم المسار للوجهة getRoute( origin: latLngPassengerLocation, diff --git a/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart b/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart index 16245bdd..f44fc64a 100644 --- a/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart +++ b/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart @@ -63,6 +63,9 @@ class RideLifecycleController extends GetxController { // --- Missing variables from monolithic controller --- String currentRideId = ''; bool isDrawingRoute = false; + // 🔥 [Fix Race] يمنع استجابة HTTP قديمة (لوجهة سابقة) من الكتابة فوق + // نتيجة أحدث عند تبديل الوجهة بسرعة داخل getDirectionMap. + int _routeRequestGeneration = 0; bool isAnotherOreder = false; bool isWhatsAppOrder = false; LatLng startLocation = const LatLng(32, 35); @@ -670,6 +673,7 @@ class RideLifecycleController extends GetxController { if (rideStatusFromStartApp['data'] == null) { _isReviewProcessed = false; + restCounter(); currentRideState.value = RideState.noRide; startMasterTimer(); return; @@ -700,6 +704,9 @@ class RideLifecycleController extends GetxController { currentRideState.value = RideState.noRide; startMasterTimer(); } else { + // 🔥 [Fix Polyline] لا حاجة للتقييم — لازم نمسح مسار الرحلة المنتهية + // قبل العودة لحالة noRide، وإلا يبقى ظاهراً على الخريطة الخاملة + restCounter(); currentRideState.value = RideState.noRide; startMasterTimer(); } @@ -3508,7 +3515,15 @@ class RideLifecycleController extends GetxController { } Future getDirectionMap(String origin, String destination, - [List waypoints = const [], int attemptCount = 0]) async { + [List waypoints = const [], + int attemptCount = 0, + int? gen]) async { + // 🔥 [Fix Race] كل طلب جديد (attemptCount == 0) يفتح "جيلاً" جديداً؛ + // إعادة المحاولات (retries) تتبع نفس الجيل. أي استجابة تصل بعد أن بدأ + // جيل أحدث (تبديل وجهة سريع) تُهمَل ولا تكتب فوق الحالة الحالية. + final int myGeneration = + attemptCount == 0 ? (++_routeRequestGeneration) : (gen ?? _routeRequestGeneration); + if (attemptCount == 0) { isDrawingRoute = true; update(); @@ -3563,7 +3578,8 @@ class RideLifecycleController extends GetxController { if (!isRequestValid) { if (attemptCount < 2) { - await _retryProcess(origin, destination, waypoints, attemptCount); + await _retryProcess( + origin, destination, waypoints, attemptCount, myGeneration); return; } _handleFatalError( @@ -3571,6 +3587,13 @@ class RideLifecycleController extends GetxController { return; } + // 🔥 [Fix Race] وجهة أحدث بدأت طلبها الخاص أثناء انتظار هذا الرد — تجاهل + if (myGeneration != _routeRequestGeneration) { + Log.print( + '🚫 Stale route response ignored (gen $myGeneration != current $_routeRequestGeneration)'); + return; + } + double apiDistanceMeters; String pointsString; dynamic routeData; @@ -3593,8 +3616,8 @@ class RideLifecycleController extends GetxController { if (attemptCount < 2) { Log.print("🔄 Retrying request (Attempt ${attemptCount + 2})..."); await Future.delayed(const Duration(seconds: 1)); - await getDirectionMap( - origin, destination, waypoints, attemptCount + 1); + await getDirectionMap(origin, destination, waypoints, + attemptCount + 1, myGeneration); return; } else { Log.print("❌ All retries failed. Calculating Route is impossible."); @@ -3628,6 +3651,14 @@ class RideLifecycleController extends GetxController { return; } + // 🔥 [Fix Race] فحص ثانٍ بعد فك التشفير في isolate منفصل (compute) — + // قد يكون طلب أحدث بدأ وانتهى خلال هذه الفترة + if (myGeneration != _routeRequestGeneration) { + Log.print( + '🚫 Stale route response ignored after decode (gen $myGeneration != current $_routeRequestGeneration)'); + return; + } + mapEngine.polylineCoordinates.clear(); mapEngine.polylineCoordinates.addAll(decodedPoints); @@ -3758,7 +3789,8 @@ class RideLifecycleController extends GetxController { Log.print('🚨 STACKTRACE: $stackTrace'); if (attemptCount < 2) { - await _retryProcess(origin, destination, waypoints, attemptCount); + await _retryProcess( + origin, destination, waypoints, attemptCount, myGeneration); } else { _handleFatalError("Connection Error".tr, "Please check your internet and try again.".tr); @@ -3767,11 +3799,11 @@ class RideLifecycleController extends GetxController { } Future _retryProcess(String origin, String dest, List waypoints, - int currentAttempt) async { + int currentAttempt, [int? gen]) async { Log.print( "🔄 Exception or Error caught. Retrying in 1s... (Attempt ${currentAttempt + 1})"); await Future.delayed(const Duration(seconds: 1)); - getDirectionMap(origin, dest, waypoints, currentAttempt + 1); + getDirectionMap(origin, dest, waypoints, currentAttempt + 1, gen); } bool _isUsingFallback = false;