Update: 2026-07-09 03:41:48
This commit is contained in:
@@ -66,26 +66,20 @@ if ($avgPricePerKm <= 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the competitor's total price based on distance and average market per-km rate
|
// Calculate the competitor's total price based on distance and average market per-km rate
|
||||||
|
// 🔥 لا يوجد أي تعديل صناعي على سعر المنافس هنا — الرقم المعروض للراكب
|
||||||
|
// يجب أن يعكس بيانات السوق الحقيقية فقط، حتى لو لم نكن أرخص فعلياً في هذه الرحلة.
|
||||||
$competitorTotalPrice = round($distance * $avgPricePerKm, 2);
|
$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
|
// Format the labels
|
||||||
$compNameAr = 'التطبيقات الأخرى';
|
$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
|
$siroCommissionRate = 0.14; // Default 14% commission
|
||||||
if ($countryCode === 'JO') $siroCommissionRate = 0.14;
|
if ($countryCode === 'JO') $siroCommissionRate = 0.14;
|
||||||
|
|||||||
@@ -119,7 +119,18 @@ try {
|
|||||||
|
|
||||||
// --- Location Server Redis ---
|
// --- Location Server Redis ---
|
||||||
$redisLocation = new 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);
|
$locPort = (int)(getenv('REDIS_LOCATION_PORT') ?: $redisPort);
|
||||||
$locPass = getenv('REDIS_LOCATION_PASSWORD') ?: $redisPass;
|
$locPass = getenv('REDIS_LOCATION_PASSWORD') ?: $redisPass;
|
||||||
|
|
||||||
@@ -127,6 +138,7 @@ try {
|
|||||||
if ($locPass) $redisLocation->auth($locPass);
|
if ($locPass) $redisLocation->auth($locPass);
|
||||||
// No prefix for location server
|
// No prefix for location server
|
||||||
} else {
|
} else {
|
||||||
|
error_log("[REDIS] ⚠️ Failed to connect \$redisLocation to $locHost:$locPort — driver-density features will be degraded.");
|
||||||
$redisLocation = null;
|
$redisLocation = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,553 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* driver_socket.php
|
|
||||||
* ==================
|
|
||||||
* WebSocket Server للسائقين — بورت 2020
|
|
||||||
* Internal HTTP Server — بورت 2021
|
|
||||||
*
|
|
||||||
* 🚀 Level 2 Architecture (Production Ready):
|
|
||||||
* - Event Buffering (Batching)
|
|
||||||
* - Redis Pipelines (تقليل الـ I/O والـ Latency بشكل كبير)
|
|
||||||
* - Memory State Cache للسائقين
|
|
||||||
* - جميع طرق HTTP (Dispatch, Market, Force Disconnect...) موجودة بالكامل
|
|
||||||
*/
|
|
||||||
|
|
||||||
use Workerman\Worker;
|
|
||||||
use Workerman\Timer;
|
|
||||||
use Workerman\Http\Client as AsyncHttp;
|
|
||||||
use PHPSocketIO\SocketIO;
|
|
||||||
use Predis\Client as RedisClient;
|
|
||||||
|
|
||||||
require_once __DIR__ . '/vendor/autoload.php';
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// ⚙️ إعدادات عامة
|
|
||||||
// ============================================================
|
|
||||||
ini_set('memory_limit', '512M');
|
|
||||||
date_default_timezone_set('Asia/Amman');
|
|
||||||
|
|
||||||
// ── Tunables (إعدادات الأداء) ──────────────────────────────────
|
|
||||||
const MIN_MOVE_METERS = 10.0; // GEOADD فقط إذا تحرك أكثر من 10 متر
|
|
||||||
const HMSET_SPEED_DELTA = 1.0; // فرق السرعة المطلوب لتحديث Redis
|
|
||||||
const HMSET_HEADING_DELTA = 5.0; // فرق الاتجاه المطلوب لتحديث Redis
|
|
||||||
const EXPIRE_REFRESH_SECONDS = 300; // 5 دقائق لتجديد الـ TTL
|
|
||||||
const FORWARD_MIN_METERS = 15.0; // HTTP forward للراكب
|
|
||||||
const FORWARD_MAX_SECONDS = 3; // أقصى مدة للـ Forward
|
|
||||||
const REDIS_BATCH_INTERVAL = 0.5; // تنفيذ مجمّع (Batch) كل نصف ثانية (500ms)
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function logMsg(string $msg): void {
|
|
||||||
echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadEnvironment(string $filePath): void {
|
|
||||||
if (!file_exists($filePath)) {
|
|
||||||
logMsg("⚠️ .env not found: $filePath");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
foreach (file($filePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
|
||||||
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue;
|
|
||||||
[$name, $value] = explode('=', $line, 2);
|
|
||||||
putenv(trim($name) . '=' . trim($value, "\"'"));
|
|
||||||
}
|
|
||||||
logMsg('✅ Environment loaded.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$siteUser = get_current_user();
|
|
||||||
$homeDir = "/home/$siteUser";
|
|
||||||
if (!is_dir($homeDir)) {
|
|
||||||
$homeDir = '/home/location'; // Fallback to original location
|
|
||||||
}
|
|
||||||
|
|
||||||
loadEnvironment(getenv('ENV_FILE_PATH') ?: ($homeDir . '/.env'));
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 🔐 مفاتيح الأمان
|
|
||||||
// ============================================================
|
|
||||||
$INTERNAL_KEY = trim((string) @file_get_contents(getenv('INTERNAL_SOCKET_KEY_PATH') ?: ($homeDir . '/.internal_socket_key')));
|
|
||||||
$redisPass = trim((string) @file_get_contents(getenv('REDIS_PASS_KEY_PATH') ?: ($homeDir . '/.reds_pass_key')));
|
|
||||||
|
|
||||||
if (empty($INTERNAL_KEY)) logMsg('❌ CRITICAL: Internal key missing!');
|
|
||||||
if (empty($redisPass)) logMsg('❌ CRITICAL: Redis password missing!');
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 🗄️ Redis Singleton
|
|
||||||
// ============================================================
|
|
||||||
$redis = null;
|
|
||||||
|
|
||||||
function getRedis(): ?RedisClient {
|
|
||||||
global $redis, $redisPass;
|
|
||||||
|
|
||||||
if ($redis !== null) {
|
|
||||||
try {
|
|
||||||
$redis->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();
|
|
||||||
@@ -79,7 +79,10 @@ function sendToLocationServer($action, $data) {
|
|||||||
|
|
||||||
function findBestDrivers($con, $lat, $lng, $carType, $endLat = null, $endLng = null) {
|
function findBestDrivers($con, $lat, $lng, $carType, $endLat = null, $endLng = null) {
|
||||||
// 1. الاتصال بـ Redis لجلب الأقرب
|
// 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() : '';
|
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
|
||||||
|
|
||||||
$postData = ['lat' => $lat, 'lng' => $lng, 'radius' => 5, 'limit' => 100];
|
$postData = ['lat' => $lat, 'lng' => $lng, 'radius' => 5, 'limit' => 100];
|
||||||
|
|||||||
@@ -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) {
|
if ($driverId > 0) {
|
||||||
|
// FCM للسائق الذي قَبِل الرحلة فعلاً (باستخدام الدالة الجديدة مع فك التشفير)
|
||||||
// أ) 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 (باستخدام الدالة الجديدة مع فك التشفير)
|
|
||||||
$driverToken = filterRequest("driver_token");
|
$driverToken = filterRequest("driver_token");
|
||||||
|
|
||||||
if (empty($driverToken)) {
|
if (empty($driverToken)) {
|
||||||
|
|||||||
@@ -1,53 +1,85 @@
|
|||||||
<?php
|
<?php
|
||||||
// cron_cleanup_waiting.php
|
// cron_ride_timeout.php
|
||||||
// مسار الاتصال بقاعدة البيانات
|
// 🔥 أُعيدت كتابته بالكامل: كان يعتمد على جدول waitingRides الذي لم يعد
|
||||||
require_once __DIR__ . '/../../get_connect.php';
|
// يُملأ من تدفق add_ride.php الحقيقي (addWaitingRide.php لا يُستدعى من هناك)،
|
||||||
|
// فكانت الرحلات العالقة بحالة 'waiting' لا تُصفَّر أبداً. الآن يعمل مباشرة
|
||||||
|
// على جدول ride باستخدام أعمدة date/time المكتوبة فعلياً عند الإنشاء.
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../../functions.php';
|
||||||
|
|
||||||
// تسجيل في اللوج لبداية العملية
|
|
||||||
error_log("⏰ [Cleanup Cron] Started cleaning old waiting rides...");
|
error_log("⏰ [Cleanup Cron] Started cleaning old waiting rides...");
|
||||||
|
|
||||||
|
$minutesLimit = 15;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// المدة المسموحة بالدقائق
|
$con = Database::get('main');
|
||||||
$minutesLimit = 15;
|
|
||||||
|
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// الخطوة 1: تحديث الحالة في الجدول الرئيسي (الأرشيف)
|
// الخطوة 1: جلب الرحلات العالقة (status='waiting' منذ أكثر من 15 دقيقة)
|
||||||
// =========================================================
|
// =========================================================
|
||||||
// نقوم بتحديث حالة الرحلات التي ستُحذف ليعلم الراكب أنها انتهت (Time Out)
|
$sqlSelect = "SELECT id, passenger_id FROM ride
|
||||||
// بدلاً من أن تبقى 'waiting' للأبد في سجلات الراكب
|
WHERE status = 'waiting'
|
||||||
|
AND TIMESTAMP(`date`, `time`) < DATE_SUB(NOW(), INTERVAL $minutesLimit MINUTE)";
|
||||||
$sqlUpdate = "UPDATE ride
|
$stmtSelect = $con->prepare($sqlSelect);
|
||||||
SET status = 'timeout'
|
$stmtSelect->execute();
|
||||||
WHERE id IN (
|
$staleRides = $stmtSelect->fetchAll(PDO::FETCH_ASSOC);
|
||||||
SELECT id FROM waitingRides
|
|
||||||
WHERE created_at < DATE_SUB(NOW(), INTERVAL $minutesLimit MINUTE)
|
|
||||||
) AND status = 'waiting'"; // نتأكد أننا نحدث ما هو معلق فقط
|
|
||||||
|
|
||||||
$stmtUpdate = $con->prepare($sqlUpdate);
|
$updatedCount = 0;
|
||||||
$stmtUpdate->execute();
|
|
||||||
$updatedCount = $stmtUpdate->rowCount();
|
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 القديم (إن وُجدت صفوف فيه من مسار قديم)
|
||||||
// =========================================================
|
// =========================================================
|
||||||
|
$deletedCount = 0;
|
||||||
$sqlDelete = "DELETE FROM waitingRides
|
try {
|
||||||
WHERE created_at < DATE_SUB(NOW(), INTERVAL $minutesLimit MINUTE)";
|
$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);
|
if ($updatedCount > 0 || $deletedCount > 0) {
|
||||||
$stmtDelete->execute();
|
$msg = "✅ [Cleanup Cron] Success: Timed out $updatedCount ride(s) in ride table, and cleaned $deletedCount legacy waitingRides row(s).";
|
||||||
$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.";
|
|
||||||
error_log($msg);
|
error_log($msg);
|
||||||
echo json_encode(["status" => "success", "message" => $msg]);
|
echo json_encode(["status" => "success", "message" => $msg]);
|
||||||
} else {
|
} else {
|
||||||
@@ -59,7 +91,6 @@ try {
|
|||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
$errorMsg = "❌ [Cleanup Cron] Error: " . $e->getMessage();
|
$errorMsg = "❌ [Cleanup Cron] Error: " . $e->getMessage();
|
||||||
error_log($errorMsg);
|
error_log($errorMsg);
|
||||||
error_log("[cron_ride_timeout] Error: " . $e->getMessage());
|
|
||||||
echo json_encode(["status" => "failure", "message" => "An internal error occurred."]);
|
echo json_encode(["status" => "failure", "message" => "An internal error occurred."]);
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<div dir="rtl" align="right">
|
||||||
|
<h1>استراتيجيات النمو والتسويق (Growth & Marketing Strategies)</h1>
|
||||||
|
|
||||||
|
<h2>1. إعادة تأطير المشكلة: السيولة والثقة</h2>
|
||||||
|
المشكلة الحقيقية في التطبيقات الجديدة ليست "الوعي بالعلامة التجارية" ولا كثرة المنشورات الإعلانية، بل هي <b>السيولة والثقة</b>:
|
||||||
|
<br>
|
||||||
|
- <b>الركاب:</b> يحتاجون لسيارة قريبة ومتاحة فوراً. السعر الأرخص لا قيمة له إذا لم يجد الراكب سيارة عند فتح التطبيق.
|
||||||
|
<br>
|
||||||
|
- <b>السائقون:</b> يحتاجون لطلبات مستمرة. إذا لم يجدوا طلبات، سيطفئون التطبيق ويعودون للمنافسين.
|
||||||
|
<br>
|
||||||
|
لذلك، الهدف ليس الانتشار الجغرافي الواسع في البداية، بل <b>الكثافة العالية في منطقة محددة ووقت محدد</b>.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>2. القاعدة الذهبية: كثافة لا انتشار</h2>
|
||||||
|
بدلاً من محاولة تغطية مدينة كاملة بميزانية محدودة:
|
||||||
|
<br>
|
||||||
|
- <b>السيطرة المحلية:</b> اختر منطقة واحدة (مثل محيط جامعة، أو حي مزدحم) وركز كل السائقين والعروض فيها حتى يصبح وقت الوصول أقل من 4 دقائق.
|
||||||
|
<br>
|
||||||
|
- الكثافة العالية تؤدي إلى: وصول سريع ← تجربة ممتازة للراكب ← حديث الناس الإيجابي بين بعضهم ← نمو مجاني.
|
||||||
|
<br>
|
||||||
|
- التوسع يتم تدريجياً لحي مجاور بعد السيطرة التامة على الحي الأول. الانتشار المبكر قاتل للميزانيات المحدودة.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>3. السائقون أولاً: توفير العرض هو الأساس</h2>
|
||||||
|
الركاب يأتون تلقائياً إذا توفرت السيارات. يجب التركيز على:
|
||||||
|
<br>
|
||||||
|
- <b>الاستغلال العالي:</b> كلما زادت رحلات السائق في الساعة الواحدة، يمكنك خفض السعر للراكب مع زيادة دخل السائق الإجمالي في نفس الوقت.
|
||||||
|
<br>
|
||||||
|
- <b>تلميح الأرباح:</b> إبراز الأرباح الإضافية التي سيجنيها السائق في كل رحلة مقارنة بالتطبيقات الأخرى بوضوح.
|
||||||
|
<br>
|
||||||
|
- <b>رسالة التوظيف:</b> التركيز على "العمولة الأقل" والدفع السريع والمباشر.
|
||||||
|
<br>
|
||||||
|
- <b>الدخل المضمون:</b> توفير أوقات دخل مضمون خلال الساعات الميتة لإبقاء السائقين متصلين ومتاحين.
|
||||||
|
<br>
|
||||||
|
- <b>مجتمع السائقين:</b> إنشاء مجموعات حقيقية للسائقين (واتساب/تيليجرام) للاستماع لهم وحل مشاكلهم بسرعة، وتفعيل نظام إحالة السائقين لجلب زملائهم.
|
||||||
|
<br>
|
||||||
|
- <b>أيام بدون عمولة:</b> تخصيص أهدأ يوم في الأسبوع ليكون بدون عمولة تماماً لتشجيع توفر السيارات بكثافة.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>4. الركاب: حلقات النمو الفعالة</h2>
|
||||||
|
- <b>إحالة ثنائية الجانب:</b> مكافأة كلا الطرفين (الداعي والمدعو) برصيد رحلات، وتفعيل المكافأة فقط <b>بعد اكتمال أول رحلة</b> لتجنب التلاعب والغش. البنية التقنية لذلك جاهزة لديك.
|
||||||
|
<br>
|
||||||
|
- <b>الاحتفاظ بالعملاء (الولاء):</b> خصم كبير على أول رحلة، ثم إشعار تذكير للرحلة الثانية، وتفعيل نظام التلعيب والولاء للمحافظة على الراكب.
|
||||||
|
<br>
|
||||||
|
- <b>استهداف المناسبات:</b> التركيز على حملات دقيقة بأوقات مدروسة (رمضان وقت الإفطار، الأعياد، بداية الفصل الجامعي). هذه الحملات مردودها أعلى بكثير من الإعلان العام.
|
||||||
|
<br>
|
||||||
|
- <b>شراكات محلية:</b> التعاون مع المقاهي والمطاعم والجامعات، واعتبارها "نقاط التقاط" مدعومة بخصومات، بالإضافة للتعاون مع المؤثرين الصغار محلياً.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>5. الثقة كسلاح ضد المنافسين القدامى</h2>
|
||||||
|
لا تنافس الشركات العملاقة على "السمعة التاريخية"، بل ركز على <b>نقاط ضعفها</b>:
|
||||||
|
<br>
|
||||||
|
- <b>السعر الثابت والشفاف:</b> تقديم وعْد للعملاء بأنه "لا مفاجآت في السعر" (استقرار الأسعار)، على عكس تقلبات الأسعار والقفزات المزعجة عند المنافسين.
|
||||||
|
<br>
|
||||||
|
- <b>الأمان المطلق:</b> إبراز ميزات هامة للثقة، خاصة للنساء (مشاركة الرحلة، زر الطوارئ، خيار تفضيل سائقة، سائقون موثقون).
|
||||||
|
<br>
|
||||||
|
- <b>الشفافية المؤسسية:</b> فيديوهات حقيقية من مؤسس التطبيق يشرح فيها بشفافية كيف يتم تسعير الرحلات ولماذا التطبيق أرخص ومربح للسائق. الصدق هو أفضل وأقوى محتوى مقنع.
|
||||||
|
<br>
|
||||||
|
- <b>الدفع النقدي السلس:</b> تسهيل التعامل بالكاش بدون تعقيدات، حيث يعتبر الكاش الملك والمفضل في العديد من أسواقنا.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>6. استراتيجية وسائل التواصل الاجتماعي السليمة</h2>
|
||||||
|
- <b>المحتوى الحقيقي:</b> قصص النجاح الواقعية وردود وتجارب الركاب الفعليين هي الذهب الحقيقي الذي يجب الاستثمار فيه.
|
||||||
|
<br>
|
||||||
|
- <b>الابتعاد عن التفاعلات الوهمية والدراما المفبركة:</b>
|
||||||
|
<br> 1. المحتوى المزور يعرض حسابات التطبيق للحظر الجماعي من قبل المنصات.
|
||||||
|
<br> 2. إذا تم كشفها، سيكون الضرر على الثقة كارثياً ولا يمكن إصلاحه بسهولة، وسيستغلها المنافسون ضدك.
|
||||||
|
<br> 3. الإعجابات الوهمية لا تزيد من "السيولة" ولا تضع سيارة حقيقية قريبة من الراكب.
|
||||||
|
<br>
|
||||||
|
- <b>البديل البناء:</b> توجيه جهود الأتمتة لإدارة مجتمع حقيقية، الرد السريع على الاستفسارات، وتسليط الضوء على آراء المستخدمين الحقيقية الموثقة بإذنهم.
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>7. إشعار تحديث الأسعار الذكي للإدارة</h2>
|
||||||
|
فكرة ممتازة تشغيلياً، ويجب أن تكون مبنية على "أحداث هامة" وليس إشعارات مستمرة لكي لا يتم تجاهلها:
|
||||||
|
<br>
|
||||||
|
- لا يتم الإرسال مع كل تحديث بسيط (ضجيج).
|
||||||
|
<br>
|
||||||
|
- <b>متى يتم الإرسال؟</b> فقط عند تجاوز التغير نسبة مئوية معينة، أو عند تفعيل نافذة تسعير موسمي أو اكتشاف ذروة جديدة.
|
||||||
|
<br>
|
||||||
|
- <b>محتوى الإشعار:</b> الدولة/المنطقة، المحرك المسؤول عن التغيير (اكتشاف ذروة / موسمي / تعديل ثبات)، السعر قبل وبعد، والسبب.
|
||||||
|
<br>
|
||||||
|
- <b>الخلاصة اليومية:</b> تقديم إشعار تجميعي بنهاية اليوم للإدارة يلخص أهم تحركات السوق.
|
||||||
|
</div>
|
||||||
@@ -440,6 +440,49 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) {
|
|||||||
logMsg("✅ Ride #$rideId taken by #$winnerDriverId.");
|
logMsg("✅ Ride #$rideId taken by #$winnerDriverId.");
|
||||||
$connection->send('OK');
|
$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)
|
// ── 7. Update Ride State (Redis Cache فقط — بدون Forward)
|
||||||
} elseif ($action === 'update_ride_state') {
|
} elseif ($action === 'update_ride_state') {
|
||||||
$rideId = $post['ride_id'] ?? null;
|
$rideId = $post['ride_id'] ?? null;
|
||||||
|
|||||||
@@ -2145,6 +2145,10 @@ class MapDriverController extends GetxController
|
|||||||
// تحديث فقط الجزء الخاص بمعلومات الراكب
|
// تحديث فقط الجزء الخاص بمعلومات الراكب
|
||||||
update();
|
update();
|
||||||
|
|
||||||
|
// 🔥 [Fix Polyline] مسح المسار الأصفر (سائق→راكب) قبل رسم الأزرق (راكب→وجهة)
|
||||||
|
// نفس علة إعادة استخدام نفس المعرف بلون جديد الموجودة في انتقال بدء الرحلة أعلاه
|
||||||
|
clearPolyline();
|
||||||
|
|
||||||
// رسم المسار للوجهة
|
// رسم المسار للوجهة
|
||||||
getRoute(
|
getRoute(
|
||||||
origin: latLngPassengerLocation,
|
origin: latLngPassengerLocation,
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ class RideLifecycleController extends GetxController {
|
|||||||
// --- Missing variables from monolithic controller ---
|
// --- Missing variables from monolithic controller ---
|
||||||
String currentRideId = '';
|
String currentRideId = '';
|
||||||
bool isDrawingRoute = false;
|
bool isDrawingRoute = false;
|
||||||
|
// 🔥 [Fix Race] يمنع استجابة HTTP قديمة (لوجهة سابقة) من الكتابة فوق
|
||||||
|
// نتيجة أحدث عند تبديل الوجهة بسرعة داخل getDirectionMap.
|
||||||
|
int _routeRequestGeneration = 0;
|
||||||
bool isAnotherOreder = false;
|
bool isAnotherOreder = false;
|
||||||
bool isWhatsAppOrder = false;
|
bool isWhatsAppOrder = false;
|
||||||
LatLng startLocation = const LatLng(32, 35);
|
LatLng startLocation = const LatLng(32, 35);
|
||||||
@@ -670,6 +673,7 @@ class RideLifecycleController extends GetxController {
|
|||||||
|
|
||||||
if (rideStatusFromStartApp['data'] == null) {
|
if (rideStatusFromStartApp['data'] == null) {
|
||||||
_isReviewProcessed = false;
|
_isReviewProcessed = false;
|
||||||
|
restCounter();
|
||||||
currentRideState.value = RideState.noRide;
|
currentRideState.value = RideState.noRide;
|
||||||
startMasterTimer();
|
startMasterTimer();
|
||||||
return;
|
return;
|
||||||
@@ -700,6 +704,9 @@ class RideLifecycleController extends GetxController {
|
|||||||
currentRideState.value = RideState.noRide;
|
currentRideState.value = RideState.noRide;
|
||||||
startMasterTimer();
|
startMasterTimer();
|
||||||
} else {
|
} else {
|
||||||
|
// 🔥 [Fix Polyline] لا حاجة للتقييم — لازم نمسح مسار الرحلة المنتهية
|
||||||
|
// قبل العودة لحالة noRide، وإلا يبقى ظاهراً على الخريطة الخاملة
|
||||||
|
restCounter();
|
||||||
currentRideState.value = RideState.noRide;
|
currentRideState.value = RideState.noRide;
|
||||||
startMasterTimer();
|
startMasterTimer();
|
||||||
}
|
}
|
||||||
@@ -3508,7 +3515,15 @@ class RideLifecycleController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getDirectionMap(String origin, String destination,
|
Future<void> getDirectionMap(String origin, String destination,
|
||||||
[List<String> waypoints = const [], int attemptCount = 0]) async {
|
[List<String> waypoints = const [],
|
||||||
|
int attemptCount = 0,
|
||||||
|
int? gen]) async {
|
||||||
|
// 🔥 [Fix Race] كل طلب جديد (attemptCount == 0) يفتح "جيلاً" جديداً؛
|
||||||
|
// إعادة المحاولات (retries) تتبع نفس الجيل. أي استجابة تصل بعد أن بدأ
|
||||||
|
// جيل أحدث (تبديل وجهة سريع) تُهمَل ولا تكتب فوق الحالة الحالية.
|
||||||
|
final int myGeneration =
|
||||||
|
attemptCount == 0 ? (++_routeRequestGeneration) : (gen ?? _routeRequestGeneration);
|
||||||
|
|
||||||
if (attemptCount == 0) {
|
if (attemptCount == 0) {
|
||||||
isDrawingRoute = true;
|
isDrawingRoute = true;
|
||||||
update();
|
update();
|
||||||
@@ -3563,7 +3578,8 @@ class RideLifecycleController extends GetxController {
|
|||||||
|
|
||||||
if (!isRequestValid) {
|
if (!isRequestValid) {
|
||||||
if (attemptCount < 2) {
|
if (attemptCount < 2) {
|
||||||
await _retryProcess(origin, destination, waypoints, attemptCount);
|
await _retryProcess(
|
||||||
|
origin, destination, waypoints, attemptCount, myGeneration);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_handleFatalError(
|
_handleFatalError(
|
||||||
@@ -3571,6 +3587,13 @@ class RideLifecycleController extends GetxController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 [Fix Race] وجهة أحدث بدأت طلبها الخاص أثناء انتظار هذا الرد — تجاهل
|
||||||
|
if (myGeneration != _routeRequestGeneration) {
|
||||||
|
Log.print(
|
||||||
|
'🚫 Stale route response ignored (gen $myGeneration != current $_routeRequestGeneration)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
double apiDistanceMeters;
|
double apiDistanceMeters;
|
||||||
String pointsString;
|
String pointsString;
|
||||||
dynamic routeData;
|
dynamic routeData;
|
||||||
@@ -3593,8 +3616,8 @@ class RideLifecycleController extends GetxController {
|
|||||||
if (attemptCount < 2) {
|
if (attemptCount < 2) {
|
||||||
Log.print("🔄 Retrying request (Attempt ${attemptCount + 2})...");
|
Log.print("🔄 Retrying request (Attempt ${attemptCount + 2})...");
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
await getDirectionMap(
|
await getDirectionMap(origin, destination, waypoints,
|
||||||
origin, destination, waypoints, attemptCount + 1);
|
attemptCount + 1, myGeneration);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
Log.print("❌ All retries failed. Calculating Route is impossible.");
|
Log.print("❌ All retries failed. Calculating Route is impossible.");
|
||||||
@@ -3628,6 +3651,14 @@ class RideLifecycleController extends GetxController {
|
|||||||
return;
|
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.clear();
|
||||||
mapEngine.polylineCoordinates.addAll(decodedPoints);
|
mapEngine.polylineCoordinates.addAll(decodedPoints);
|
||||||
|
|
||||||
@@ -3758,7 +3789,8 @@ class RideLifecycleController extends GetxController {
|
|||||||
Log.print('🚨 STACKTRACE: $stackTrace');
|
Log.print('🚨 STACKTRACE: $stackTrace');
|
||||||
|
|
||||||
if (attemptCount < 2) {
|
if (attemptCount < 2) {
|
||||||
await _retryProcess(origin, destination, waypoints, attemptCount);
|
await _retryProcess(
|
||||||
|
origin, destination, waypoints, attemptCount, myGeneration);
|
||||||
} else {
|
} else {
|
||||||
_handleFatalError("Connection Error".tr,
|
_handleFatalError("Connection Error".tr,
|
||||||
"Please check your internet and try again.".tr);
|
"Please check your internet and try again.".tr);
|
||||||
@@ -3767,11 +3799,11 @@ class RideLifecycleController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _retryProcess(String origin, String dest, List<String> waypoints,
|
Future<void> _retryProcess(String origin, String dest, List<String> waypoints,
|
||||||
int currentAttempt) async {
|
int currentAttempt, [int? gen]) async {
|
||||||
Log.print(
|
Log.print(
|
||||||
"🔄 Exception or Error caught. Retrying in 1s... (Attempt ${currentAttempt + 1})");
|
"🔄 Exception or Error caught. Retrying in 1s... (Attempt ${currentAttempt + 1})");
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
getDirectionMap(origin, dest, waypoints, currentAttempt + 1);
|
getDirectionMap(origin, dest, waypoints, currentAttempt + 1, gen);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isUsingFallback = false;
|
bool _isUsingFallback = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user