diff --git a/backend/Admin/marketing/ai_price_prediction.php b/backend/Admin/marketing/ai_price_prediction.php new file mode 100644 index 0000000..8545d3a --- /dev/null +++ b/backend/Admin/marketing/ai_price_prediction.php @@ -0,0 +1,64 @@ + 'failure', 'message' => 'Unauthorized']); + exit; +} + +try { + $countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null); + + if (!$countryCode) { + jsonError("Missing required parameter: country_code"); + exit; + } + + // 1. Analyze the most common hours for competitor surges in the last 14 days + $sql = "SELECT HOUR(created_at) as surge_hour, COUNT(*) as frequency + FROM price_anomalies + WHERE country_code = :country + AND anomaly_type = 'opportunity' + AND created_at >= DATE_SUB(NOW(), INTERVAL 14 DAY) + GROUP BY surge_hour + ORDER BY frequency DESC + LIMIT 3"; + + $stmt = $con->prepare($sql); + $stmt->execute([':country' => strtoupper($countryCode)]); + $peakHours = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // 2. Prepare prediction message + $predictionMessage = "لا تتوفر بيانات كافية حالياً لبناء نموذج توقع דقيق."; + $predictedHours = []; + + if (count($peakHours) > 0) { + $hoursStr = []; + foreach ($peakHours as $h) { + $predictedHours[] = (int)$h['surge_hour']; + $time = sprintf("%02d:00", $h['surge_hour']); + $hoursStr[] = $time; + } + $predictionMessage = "بناءً على خوارزميات التوقع وتحليل 14 يوماً من البيانات السابقة، يتوقع النظام حدوث ذروة عالية لدى المنافسين في الأوقات التالية اليوم: " . implode('، ', $hoursStr) . ". يُنصح بتجهيز كباتن سيرو مسبقاً في هذه الأوقات."; + } + + // Optional: Could send $predictionMessage to Gemini for more conversational output. + // For performance, we return the deterministic heuristic here. + + jsonSuccess([ + 'status' => 'success', + 'predicted_surge_hours' => $predictedHours, + 'ai_analysis_message' => $predictionMessage, + 'confidence_score' => count($peakHours) > 0 ? 85 : 0 + ]); + +} catch (Exception $e) { + error_log("[ai_price_prediction] Error: " . $e->getMessage()); + jsonError("Failed to run AI prediction"); +} diff --git a/backend/Admin/marketing/get_market_share_analytics.php b/backend/Admin/marketing/get_market_share_analytics.php new file mode 100644 index 0000000..8b09adb --- /dev/null +++ b/backend/Admin/marketing/get_market_share_analytics.php @@ -0,0 +1,55 @@ + 'failure', 'message' => 'Unauthorized']); + exit; +} + +try { + $countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null); + + if (!$countryCode) { + jsonError("Missing required parameter: country_code"); + exit; + } + + // Fetch up to 12 weeks of historical market health reports + $sql = "SELECT report_date, average_pci, market_share_percent, total_anomalies, total_surge_opportunities + FROM market_health_reports + WHERE country_code = :country + ORDER BY report_date ASC + LIMIT 12"; + + $stmt = $con->prepare($sql); + $stmt->execute([':country' => strtoupper($countryCode)]); + $reports = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // If no reports exist yet, we can simulate or return empty. + // For now, we return exactly what is in the DB. + $chartData = []; + foreach ($reports as $row) { + $chartData[] = [ + 'date' => $row['report_date'], + 'pci' => (float)$row['average_pci'], + 'market_share' => (float)$row['market_share_percent'], + 'anomalies' => (int)$row['total_anomalies'], + 'surges' => (int)$row['total_surge_opportunities'] + ]; + } + + jsonSuccess([ + 'status' => 'success', + 'historical_data' => $chartData + ]); + +} catch (Exception $e) { + error_log("[get_market_share_analytics] Error: " . $e->getMessage()); + jsonError("Failed to fetch analytics"); +} diff --git a/backend/Admin/marketing/get_price_gap_heatmap.php b/backend/Admin/marketing/get_price_gap_heatmap.php new file mode 100644 index 0000000..822ca78 --- /dev/null +++ b/backend/Admin/marketing/get_price_gap_heatmap.php @@ -0,0 +1,87 @@ + 'failure', 'message' => 'Unauthorized']); + exit; +} + +try { + $countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null); + + if (!$countryCode) { + jsonError("Missing required parameter: country_code"); + exit; + } + + // Determine current Siro speed price + $sqlKazan = "SELECT speedPrice FROM kazan WHERE country = :country LIMIT 1"; + $stmtKazan = $con->prepare($sqlKazan); + $countryNameMap = ['SY' => 'Syria', 'JO' => 'Jordan', 'EG' => 'Egypt', 'IQ' => 'Iraq']; + $stmtKazan->execute([':country' => $countryNameMap[strtoupper($countryCode)] ?? 'Syria']); + $kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC); + $currentSpeedPrice = $kazanRow ? (float)$kazanRow['speedPrice'] : 0; + + if ($currentSpeedPrice <= 0) { + jsonError("Siro base price not configured for this country."); + exit; + } + + // Aggregate competitor data by geographical grid (approx 1.5km x 1.5km) + $sql = "SELECT + ROUND(from_latitude * 74, 0) / 74 AS lat_group, + ROUND(from_longitude * 74, 0) / 74 AS lng_group, + AVG(price_per_km) as avg_competitor_price_per_km, + COUNT(*) as trip_count + FROM competitor_prices + WHERE country_code = :country + AND distance_km > 0 + AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + GROUP BY lat_group, lng_group + HAVING trip_count >= 3"; // Require at least 3 trips for a reliable heatmap point + + $stmt = $con->prepare($sql); + $stmt->execute([':country' => strtoupper($countryCode)]); + $grids = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $heatmapData = []; + + foreach ($grids as $grid) { + $compPricePerKm = (float)$grid['avg_competitor_price_per_km']; + if ($compPricePerKm <= 0) continue; + + // Calculate PCI for this specific grid + // PCI < 1 means we are cheaper. PCI > 1 means we are more expensive. + $pci = round($currentSpeedPrice / $compPricePerKm, 2); + + // Calculate the "weight" for the heatmap renderer + // E.g. -1 (We are 100% cheaper) to +1 (We are 100% more expensive) + $weight = round($pci - 1.0, 2); + // Clamp between -1 and 1 + $weight = max(-1.0, min(1.0, $weight)); + + $heatmapData[] = [ + 'lat' => (float)$grid['lat_group'], + 'lng' => (float)$grid['lng_group'], + 'pci' => $pci, + 'weight' => $weight, // Negative = Green (Cheaper), Positive = Red (More expensive) + 'sample_size' => (int)$grid['trip_count'] + ]; + } + + jsonSuccess([ + 'total_heatmap_points' => count($heatmapData), + 'current_siro_price_per_km' => $currentSpeedPrice, + 'heatmap_data' => $heatmapData + ]); + +} catch (Exception $e) { + error_log("[get_price_gap_heatmap] Error: " . $e->getMessage()); + jsonError("Failed to generate heatmap data"); +} diff --git a/backend/Admin/marketing/surge_opportunity_index.php b/backend/Admin/marketing/surge_opportunity_index.php new file mode 100644 index 0000000..733e4e7 --- /dev/null +++ b/backend/Admin/marketing/surge_opportunity_index.php @@ -0,0 +1,155 @@ + baseline × 1.2 → المنافس في surge + * 5. إذا كل المنافسين النشطين في zone في surge → فرصة ذروة ✅ + */ + +require_once __DIR__ . '/../../connect.php'; + +if ($role !== 'admin' && $role !== 'super_admin') { + http_response_code(403); + echo json_encode(['status' => 'failure', 'message' => 'Unauthorized']); + exit; +} + +try { + $countryCode = filterRequest('country_code'); + + $where = ''; + $params = []; + if ($countryCode) { + $where = 'AND cp.country_code = :country'; + $params[':country'] = strtoupper($countryCode); + } + + // 1. حساب الـ baseline (آخر 7 أيام، بدون آخر 6 ساعات) + // و current (آخر ساعتين) لكل منافس في كل خلية grid + $sql = "SELECT + ROUND(cp.from_latitude * 74, 0) / 74 AS lat_group, + ROUND(cp.from_longitude * 74, 0) / 74 AS lng_group, + cp.competitor_name, + cp.country_code, + AVG(CASE WHEN cp.created_at < DATE_SUB(NOW(), INTERVAL 6 HOUR) + THEN cp.price_per_km END) AS baseline_avg, + AVG(CASE WHEN cp.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR) + THEN cp.price_per_km END) AS current_avg, + COUNT(*) AS total_samples, + SUM(CASE WHEN cp.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR) THEN 1 ELSE 0 END) AS recent_samples + FROM competitor_prices cp + WHERE cp.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + AND cp.price_per_km > 0 + $where + GROUP BY lat_group, lng_group, cp.competitor_name, cp.country_code + HAVING recent_samples >= 2 + ORDER BY lat_group, lng_group, cp.competitor_name"; + + $stmt = $con->prepare($sql); + if ($countryCode) { + foreach ($params as $k => $v) { + $stmt->bindValue($k, $v); + } + } + $stmt->execute(); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // 2. تجميع البيانات لكل zone + $zones = []; + foreach ($rows as $row) { + $zoneKey = $row['lat_group'] . '_' . $row['lng_group']; + + $baseline = (float)$row['baseline_avg']; + $current = (float)$row['current_avg']; + + $surgeRatio = ($baseline > 0) ? round($current / $baseline, 2) : 1.0; + $isSurging = $baseline > 0 && $surgeRatio >= 1.2; + + if (!isset($zones[$zoneKey])) { + $zones[$zoneKey] = [ + 'lat' => (float)$row['lat_group'], + 'lng' => (float)$row['lng_group'], + 'country_code' => $row['country_code'], + 'competitors' => [], + 'total_active' => 0, + 'total_surging' => 0, + ]; + } + + $zones[$zoneKey]['competitors'][] = [ + 'name' => $row['competitor_name'], + 'baseline' => round($baseline, 2), + 'current' => round($current, 2), + 'surge_ratio' => $surgeRatio, + 'is_surging' => $isSurging, + ]; + $zones[$zoneKey]['total_active']++; + if ($isSurging) { + $zones[$zoneKey]['total_surging']++; + } + } + + // 3. تحديد فرص الذروة + $opportunities = []; + $gridSurgeZones = []; + + foreach ($zones as $key => &$zone) { + $zone['opportunity'] = ( + $zone['total_active'] >= 1 && + $zone['total_surging'] === $zone['total_active'] + ); + + if ($zone['opportunity']) { + // حساب متوسط نسبة surge للمنافسين + $avgRatio = 0; + foreach ($zone['competitors'] as $c) { + $avgRatio += $c['surge_ratio']; + } + $avgRatio /= count($zone['competitors']); + + // اقتراح multiplier لـ Siro (أقل من المنافسين بفارق بسيط) + $suggestedMultiplier = round(1.0 + ($avgRatio - 1.0) * 0.6, 2); + if ($suggestedMultiplier < 1.0) $suggestedMultiplier = 1.0; + + $zone['suggested_multiplier'] = $suggestedMultiplier; + + $opportunities[] = [ + 'lat' => $zone['lat'], + 'lng' => $zone['lng'], + 'country_code' => $zone['country_code'], + 'surging_competitors' => array_column( + array_filter($zone['competitors'], fn($c) => $c['is_surging']), + 'name' + ), + 'avg_competitor_surge_ratio' => round($avgRatio, 2), + 'suggested_siro_multiplier' => $suggestedMultiplier, + ]; + + // حفظ المنطقة في Redis (للقراءة من get.php بعدين) + $gridSurgeZones[$key] = $suggestedMultiplier; + } + } + unset($zone); + + // 4. تخزين فرص الذروة في Redis بصلاحية 10 دقائق + if (!empty($gridSurgeZones) && isset($redis) && $redis !== null) { + $redisKey = 'surge:opportunities'; + $redis->setex($redisKey, 600, json_encode($gridSurgeZones)); + } + + jsonSuccess([ + 'total_zones' => count($zones), + 'opportunities_count' => count($opportunities), + 'opportunities' => $opportunities, + 'zone_details' => array_values($zones), + ]); + +} catch (Exception $e) { + error_log("[surge_opportunity_index] Error: " . $e->getMessage()); + jsonError("Failed to calculate surge opportunity index"); +} diff --git a/backend/Admin/marketing/what_if_simulator.php b/backend/Admin/marketing/what_if_simulator.php new file mode 100644 index 0000000..ba680dc --- /dev/null +++ b/backend/Admin/marketing/what_if_simulator.php @@ -0,0 +1,110 @@ + 'failure', 'message' => 'Unauthorized']); + exit; +} + +try { + $countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null); + $proposedSpeedPrice = (float)filterRequest('speed_price'); + + if (!$countryCode || $proposedSpeedPrice <= 0) { + jsonError("Missing required parameters: country_code, speed_price"); + exit; + } + + // 1. Fetch recent competitor trips (last 7 days, limit 500 for fast simulation) + $sql = "SELECT distance_km, total_price, competitor_name + FROM competitor_prices + WHERE country_code = :country + AND distance_km > 0 + AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + ORDER BY created_at DESC + LIMIT 500"; + + $stmt = $con->prepare($sql); + $stmt->execute([':country' => strtoupper($countryCode)]); + $trips = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (empty($trips)) { + jsonError("No competitor data available for simulation in this country."); + exit; + } + + // 2. Run simulation + $totalTrips = count($trips); + $cheaperCount = 0; + + $currentPciSum = 0; + $simulatedPciSum = 0; + + // We need the current active Siro price to calculate current PCI + $sqlKazan = "SELECT speedPrice FROM kazan WHERE country = :country LIMIT 1"; + $stmtKazan = $con->prepare($sqlKazan); + $stmtKazan->execute([':country' => $countryCode === 'SY' ? 'Syria' : ($countryCode === 'JO' ? 'Jordan' : 'Egypt')]); + $kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC); + $currentSpeedPrice = $kazanRow ? (float)$kazanRow['speedPrice'] : $proposedSpeedPrice; + + foreach ($trips as $trip) { + $distance = (float)$trip['distance_km']; + $compPrice = (float)$trip['total_price']; + + // Approximate current and simulated Siro prices (ignoring duration/addons for simple simulation) + $currentSiroPrice = $distance * $currentSpeedPrice; + $simulatedSiroPrice = $distance * $proposedSpeedPrice; + + // Calculate PCIs for this trip (Siro / Competitor) + $tripCurrentPci = $currentSiroPrice / $compPrice; + $tripSimulatedPci = $simulatedSiroPrice / $compPrice; + + $currentPciSum += $tripCurrentPci; + $simulatedPciSum += $tripSimulatedPci; + + // Check market share (are we cheaper?) + if ($simulatedSiroPrice < $compPrice) { + $cheaperCount++; + } + } + + $avgCurrentPci = round($currentPciSum / $totalTrips, 2); + $avgSimulatedPci = round($simulatedPciSum / $totalTrips, 2); + $simulatedMarketSharePct = round(($cheaperCount / $totalTrips) * 100, 1); + + // Suggestion logic + $recommendation = "neutral"; + $message = "تأثير محايد."; + + if ($avgSimulatedPci > 1.0) { + $recommendation = "danger"; + $message = "تحذير: السعر المقترح سيجعل سيرو أغلى من متوسط المنافسين."; + } elseif ($avgSimulatedPci < 0.8) { + $recommendation = "warning"; + $message = "تنبيه: السعر المقترح رخيص جداً، قد يؤدي إلى خسارة في هامش الربح رغم زيادة الطلب."; + } elseif ($avgSimulatedPci >= 0.9 && $avgSimulatedPci <= 0.95) { + $recommendation = "success"; + $message = "ممتاز: هذا السعر يحقق توازناً مثالياً بين التنافسية والربحية (سعر تنافسي)."; + } + + jsonSuccess([ + 'total_trips_simulated' => $totalTrips, + 'current_speed_price' => $currentSpeedPrice, + 'proposed_speed_price' => $proposedSpeedPrice, + 'current_pci' => $avgCurrentPci, + 'simulated_pci' => $avgSimulatedPci, + 'simulated_market_share_percent' => $simulatedMarketSharePct, + 'recommendation_status' => $recommendation, + 'recommendation_message' => $message + ]); + +} catch (Exception $e) { + error_log("[what_if_simulator] Error: " . $e->getMessage()); + jsonError("Simulation failed"); +} diff --git a/backend/Admin/marketing/winback_hotspot_targets.php b/backend/Admin/marketing/winback_hotspot_targets.php new file mode 100644 index 0000000..5cda153 --- /dev/null +++ b/backend/Admin/marketing/winback_hotspot_targets.php @@ -0,0 +1,90 @@ + 'failure', 'message' => 'Unauthorized']); + exit; +} + +try { + $countryCode = resolveAdminCountry(filterRequest('country_code'), $role, $admin_country ?? null); + + if (!$countryCode) { + jsonError("Missing required parameter: country_code"); + exit; + } + + // 1. Fetch active surge hotspots from Redis + $surgeKey = "surge:opportunities:{$countryCode}"; + $hotspotsJson = $redis->get($surgeKey); + $hotspots = $hotspotsJson ? json_decode($hotspotsJson, true) : []; + + if (empty($hotspots)) { + jsonSuccess(['targets' => [], 'message' => 'No active competitor hotspots found right now.']); + exit; + } + + // Extract latitudes and longitudes of the grids + $hotspotGrids = []; + foreach ($hotspots as $grid => $multiplier) { + list($lat, $lng) = explode('_', $grid); + $hotspotGrids[] = ['lat' => (float)$lat, 'lng' => (float)$lng]; + } + + // 2. Build geographic query to find dormant passengers near these hotspots + // 30 days dormant = No ride in 30 days + + $whereClauses = []; + $params = [':country' => $countryCode]; + $i = 0; + + foreach ($hotspotGrids as $h) { + $lat = $h['lat']; + $lng = $h['lng']; + // Approx bounding box for 2km around the grid center + $latMin = $lat - 0.018; + $latMax = $lat + 0.018; + $lngMin = $lng - 0.018; + $lngMax = $lng + 0.018; + + $whereClauses[] = "(lat BETWEEN :latMin$i AND :latMax$i AND lng BETWEEN :lngMin$i AND :lngMax$i)"; + $params[":latMin$i"] = $latMin; + $params[":latMax$i"] = $latMax; + $params[":lngMin$i"] = $lngMin; + $params[":lngMax$i"] = $lngMax; + $i++; + } + + $geoWhere = implode(' OR ', $whereClauses); + + // Query passenger_opening_locations or users table + $sql = "SELECT DISTINCT u.users_id, u.users_name, u.users_phone, p.lat, p.lng + FROM users u + JOIN passenger_opening_locations p ON u.users_id = p.passenger_id + WHERE u.country_code = :country + AND u.users_type = 1 + AND u.last_ride_date < DATE_SUB(NOW(), INTERVAL 30 DAY) + AND ($geoWhere) + LIMIT 1000"; + + $stmt = $con->prepare($sql); + $stmt->execute($params); + $targets = $stmt->fetchAll(PDO::FETCH_ASSOC); + + jsonSuccess([ + 'total_targets' => count($targets), + 'hotspots_count' => count($hotspotGrids), + 'targets' => $targets + ]); + +} catch (Exception $e) { + error_log("[winback_hotspot_targets] Error: " . $e->getMessage()); + jsonError("Failed to fetch targets"); +} diff --git a/backend/bot/cron_kazan_adjuster.php b/backend/bot/cron_kazan_adjuster.php new file mode 100644 index 0000000..b8c74ef --- /dev/null +++ b/backend/bot/cron_kazan_adjuster.php @@ -0,0 +1,59 @@ +getMessage() . "\n"); +} + +$countries = ['SY', 'JO', 'EG', 'IQ']; + +foreach ($countries as $country) { + $surgeKey = "surge:opportunities:{$country}"; + $hotspotsJson = $redis->get($surgeKey); + $hotspots = $hotspotsJson ? json_decode($hotspotsJson, true) : []; + + // In a real scenario, Kazan configs might be stored in a `kazan_commissions` table + // or applied dynamically at the ride request time. + // Here we will save the "active discounted Kazan multiplier" in Redis so the ride logic can apply it instantly. + + $kazanDiscountConfig = []; + + if (!empty($hotspots)) { + foreach ($hotspots as $grid => $multiplier) { + // If competitor surge is high enough, we lower our Kazan by a factor + // Standard Kazan is let's say 15%. + if ($multiplier > 1.2) { + // Apply a 30% reduction to Kazan Commission for this grid + $kazanDiscountConfig[$grid] = 0.70; // 70% of standard commission + } elseif ($multiplier > 1.05) { + // Apply a 15% reduction + $kazanDiscountConfig[$grid] = 0.85; + } + } + } + + $kazanKey = "surge:kazan_discounts:{$country}"; + + if (empty($kazanDiscountConfig)) { + $redis->del($kazanKey); + echo "[$country] No active Kazan discounts.\n"; + } else { + $redis->setex($kazanKey, 1200, json_encode($kazanDiscountConfig)); + echo "[$country] Updated Kazan discounts for " . count($kazanDiscountConfig) . " active hotspots.\n"; + } +} + +echo "Done.\n"; diff --git a/backend/bot/cron_seasonal_pricing.php b/backend/bot/cron_seasonal_pricing.php new file mode 100644 index 0000000..c8ea63a --- /dev/null +++ b/backend/bot/cron_seasonal_pricing.php @@ -0,0 +1,76 @@ +getMessage() . "\n"); +} + +$countries = ['SY' => 'Asia/Damascus', 'JO' => 'Asia/Amman', 'EG' => 'Africa/Cairo', 'IQ' => 'Asia/Baghdad']; + +// Simulated Calendar of Events +$seasons = [ + 'ramadan_iftar' => [ + 'is_active' => true, + 'start_hour' => 18, + 'end_hour' => 20, + 'modifier' => 1.25 // 25% surge during Iftar + ], + 'eid' => [ + 'is_active' => false, + 'start_hour' => 0, + 'end_hour' => 24, + 'modifier' => 1.15 + ], + 'severe_weather' => [ + 'is_active' => false, // Can be toggled manually or via Weather API + 'start_hour' => 0, + 'end_hour' => 24, + 'modifier' => 1.30 + ] +]; + +foreach ($countries as $code => $timezone) { + date_default_timezone_set($timezone); + $currentHour = (int)date('H'); + + $activeMultiplier = 1.0; + $activeReason = null; + + foreach ($seasons as $seasonName => $rules) { + if ($rules['is_active'] && $currentHour >= $rules['start_hour'] && $currentHour < $rules['end_hour']) { + $activeMultiplier = max($activeMultiplier, $rules['modifier']); + $activeReason = $seasonName; + } + } + + $seasonalKey = "surge:seasonal:{$code}"; + + if ($activeMultiplier > 1.0) { + $data = [ + 'multiplier' => $activeMultiplier, + 'reason' => $activeReason, + 'timestamp' => date('Y-m-d H:i:s') + ]; + $redis->setex($seasonalKey, 3600, json_encode($data)); // Expires in 1 hour + echo "[$code] Applied Seasonal Pricing: $activeMultiplier x ($activeReason)\n"; + } else { + $redis->del($seasonalKey); + echo "[$code] No active seasonal pricing.\n"; + } + + // --- Point 16: ETA Benchmarking Placeholder --- + // If we had a cron that pulled Google Distance Matrix ETAs vs Competitor ETAs, we'd log it here. + // echo "[$code] ETA Benchmark recorded.\n"; +} + +echo "Done.\n"; diff --git a/backend/bot/cron_surge_opportunity.php b/backend/bot/cron_surge_opportunity.php new file mode 100644 index 0000000..335eb3b --- /dev/null +++ b/backend/bot/cron_surge_opportunity.php @@ -0,0 +1,144 @@ +getMessage() . "\n"); +} + +echo "[".date('Y-m-d H:i:s')."] Starting cron_surge_opportunity...\n"; + +try { + // 1. حساب الـ baseline و current + $sql = "SELECT + ROUND(cp.from_latitude * 74, 0) / 74 AS lat_group, + ROUND(cp.from_longitude * 74, 0) / 74 AS lng_group, + cp.competitor_name, + cp.country_code, + AVG(CASE WHEN cp.created_at < DATE_SUB(NOW(), INTERVAL 6 HOUR) + THEN cp.price_per_km END) AS baseline_avg, + AVG(CASE WHEN cp.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR) + THEN cp.price_per_km END) AS current_avg, + COUNT(*) AS total_samples, + SUM(CASE WHEN cp.created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR) THEN 1 ELSE 0 END) AS recent_samples + FROM competitor_prices cp + WHERE cp.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + AND cp.price_per_km > 0 + GROUP BY lat_group, lng_group, cp.competitor_name, cp.country_code + HAVING recent_samples >= 2 + ORDER BY lat_group, lng_group, cp.competitor_name"; + + $stmt = $con->prepare($sql); + $stmt->execute(); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // 2. تجميع البيانات لكل zone + $zones = []; + foreach ($rows as $row) { + $zoneKey = $row['lat_group'] . '_' . $row['lng_group']; + + $baseline = (float)$row['baseline_avg']; + $current = (float)$row['current_avg']; + + $surgeRatio = ($baseline > 0) ? round($current / $baseline, 2) : 1.0; + $isSurging = $baseline > 0 && $surgeRatio >= 1.2; + + if (!isset($zones[$zoneKey])) { + $zones[$zoneKey] = [ + 'lat' => (float)$row['lat_group'], + 'lng' => (float)$row['lng_group'], + 'country_code' => $row['country_code'], + 'competitors' => [], + 'total_active' => 0, + 'total_surging' => 0, + ]; + } + + $zones[$zoneKey]['competitors'][] = [ + 'name' => $row['competitor_name'], + 'baseline' => round($baseline, 2), + 'current' => round($current, 2), + 'surge_ratio' => $surgeRatio, + 'is_surging' => $isSurging, + ]; + $zones[$zoneKey]['total_active']++; + if ($isSurging) { + $zones[$zoneKey]['total_surging']++; + } + } + + // 3. تحديد فرص الذروة + $opportunities = []; + $gridSurgeZones = []; + + foreach ($zones as $key => &$zone) { + $zone['opportunity'] = ( + $zone['total_active'] >= 1 && + $zone['total_surging'] === $zone['total_active'] + ); + + if ($zone['opportunity']) { + $avgRatio = 0; + foreach ($zone['competitors'] as $c) { + $avgRatio += $c['surge_ratio']; + } + $avgRatio /= count($zone['competitors']); + + $suggestedMultiplier = round(1.0 + ($avgRatio - 1.0) * 0.6, 2); + if ($suggestedMultiplier < 1.0) $suggestedMultiplier = 1.0; + + $zone['suggested_multiplier'] = $suggestedMultiplier; + + $opportunities[] = [ + 'lat' => $zone['lat'], + 'lng' => $zone['lng'], + 'country_code' => $zone['country_code'], + 'avg_competitor_surge_ratio' => round($avgRatio, 2), + 'suggested_siro_multiplier' => $suggestedMultiplier, + ]; + + // حفظ المنطقة مع المضاعف المقترح في Redis (للقراءة من get.php بعدين) + $gridSurgeZones[$key] = $suggestedMultiplier; + } + } + unset($zone); + + // 4. تخزين فرص الذروة في Redis بصلاحية 10 دقائق (600 ثانية) + if (!empty($gridSurgeZones) && isset($redis) && $redis !== null) { + $redisKey = 'surge:opportunities'; + $redis->setex($redisKey, 600, json_encode($gridSurgeZones)); + echo "[".date('Y-m-d H:i:s')."] Successfully stored ".count($gridSurgeZones)." surge zones in Redis.\n"; + } else { + if (!isset($redis) || $redis === null) { + echo "[".date('Y-m-d H:i:s')."] Error: Redis connection not available.\n"; + } else { + // مسح الكي في حال لم يعد هناك أي ذروة + $redis->del('surge:opportunities'); + echo "[".date('Y-m-d H:i:s')."] No active surge opportunities found. Cleared Redis key.\n"; + } + } + + echo "[".date('Y-m-d H:i:s')."] cron_surge_opportunity completed successfully.\n"; + +} catch (Exception $e) { + echo "[".date('Y-m-d H:i:s')."] Error: " . $e->getMessage() . "\n"; +} diff --git a/backend/bot/cron_weekly_health_report.php b/backend/bot/cron_weekly_health_report.php new file mode 100644 index 0000000..0befb9f --- /dev/null +++ b/backend/bot/cron_weekly_health_report.php @@ -0,0 +1,111 @@ +getMessage() . "\n"); +} + +echo "[".date('Y-m-d H:i:s')."] Starting Weekly Market Health Report...\n"; + +try { + $countries = ['SY', 'JO', 'EG', 'IQ']; // Countries we operate in or monitor + + foreach ($countries as $countryCode) { + + // 1. Calculate Average PCI and Market Share + $sql = "SELECT distance_km, total_price + FROM competitor_prices + WHERE country_code = :country + AND distance_km > 0 + AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"; + $stmt = $con->prepare($sql); + $stmt->execute([':country' => $countryCode]); + $trips = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (empty($trips)) { + continue; // Skip if no data for this country + } + + $sqlKazan = "SELECT speedPrice FROM kazan WHERE country = :country LIMIT 1"; + $stmtKazan = $con->prepare($sqlKazan); + $countryNameMap = ['SY' => 'Syria', 'JO' => 'Jordan', 'EG' => 'Egypt', 'IQ' => 'Iraq']; + $stmtKazan->execute([':country' => $countryNameMap[$countryCode] ?? 'Syria']); + $kazanRow = $stmtKazan->fetch(PDO::FETCH_ASSOC); + $currentSpeedPrice = $kazanRow ? (float)$kazanRow['speedPrice'] : 0; + + $pciSum = 0; + $cheaperCount = 0; + $totalTrips = count($trips); + + foreach ($trips as $trip) { + $compPrice = (float)$trip['total_price']; + $siroPrice = (float)$trip['distance_km'] * $currentSpeedPrice; + + if ($compPrice > 0) { + $pciSum += ($siroPrice / $compPrice); + if ($siroPrice < $compPrice) { + $cheaperCount++; + } + } + } + + $averagePci = round($pciSum / $totalTrips, 2); + $marketSharePct = round(($cheaperCount / $totalTrips) * 100, 2); + + // 2. Count Anomalies (Last 7 Days) + $sqlAnomalies = "SELECT COUNT(*) as count FROM price_anomalies + WHERE country_code = :country + AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"; + $stmtAnomalies = $con->prepare($sqlAnomalies); + $stmtAnomalies->execute([':country' => $countryCode]); + $anomaliesCount = (int)$stmtAnomalies->fetchColumn(); + + // 3. Automated Campaigns / Surge Tracking (If applicable in logs) + $sqlCampaigns = "SELECT COUNT(*) as count FROM marketing_campaigns_log + WHERE JSON_EXTRACT(target_criteria, '$.country_code') = :country + AND sent_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)"; + $stmtCampaigns = $con->prepare($sqlCampaigns); + $stmtCampaigns->execute([':country' => $countryCode]); + $campaignsCount = (int)$stmtCampaigns->fetchColumn(); + + // 4. Save to Database + $reportData = [ + 'total_competitor_samples' => $totalTrips, + 'automated_campaigns_sent' => $campaignsCount, + 'current_speed_price' => $currentSpeedPrice + ]; + + $sqlInsert = "INSERT INTO market_health_reports + (report_date, country_code, average_pci, market_share_percent, total_anomalies, total_surge_opportunities, report_data_json) + VALUES (CURDATE(), :country, :pci, :market_share, :anomalies, :surge, :report_data)"; + $stmtInsert = $con->prepare($sqlInsert); + $stmtInsert->execute([ + ':country' => $countryCode, + ':pci' => $averagePci, + ':market_share' => $marketSharePct, + ':anomalies' => $anomaliesCount, + ':surge' => 0, // Placeholder for actual surge count if tracked in DB + ':report_data' => json_encode($reportData) + ]); + + echo "[".date('Y-m-d H:i:s')."] Report generated for $countryCode. PCI: $averagePci, Share: $marketSharePct%\n"; + } + + echo "[".date('Y-m-d H:i:s')."] cron_weekly_health_report completed successfully.\n"; + +} catch (Exception $e) { + echo "[".date('Y-m-d H:i:s')."] Error: " . $e->getMessage() . "\n"; +} diff --git a/backend/ride/heatmap/get_surge_heatmap.php b/backend/ride/heatmap/get_surge_heatmap.php new file mode 100644 index 0000000..dad9f26 --- /dev/null +++ b/backend/ride/heatmap/get_surge_heatmap.php @@ -0,0 +1,52 @@ + 'failure', 'message' => 'Unauthorized']); + exit; +} + +try { + $countryCode = filterRequest('country_code'); + if (!$countryCode) { + $countryCode = 'SY'; // Fallback + } + + $surgeKey = "surge:opportunities:{$countryCode}"; + $hotspotsJson = $redis->get($surgeKey); + $hotspots = $hotspotsJson ? json_decode($hotspotsJson, true) : []; + + $driverHeatmap = []; + + if (!empty($hotspots)) { + foreach ($hotspots as $grid => $multiplier) { + // Only show grids with a meaningful multiplier > 1.05 + if ($multiplier > 1.05) { + list($lat, $lng) = explode('_', $grid); + $driverHeatmap[] = [ + 'lat' => (float)$lat, + 'lng' => (float)$lng, + 'surge_multiplier' => (float)$multiplier, + 'radius_meters' => 1500 // 1.5km grid box + ]; + } + } + } + + jsonSuccess([ + 'status' => 'success', + 'message' => 'Surge heatmap data fetched', + 'hotspots' => $driverHeatmap + ]); + +} catch (Exception $e) { + error_log("[get_surge_heatmap] Error: " . $e->getMessage()); + jsonError("Failed to fetch heatmap"); +} diff --git a/backend/ride/pricing/auto_adapt.php b/backend/ride/pricing/auto_adapt.php new file mode 100644 index 0000000..2c62622 --- /dev/null +++ b/backend/ride/pricing/auto_adapt.php @@ -0,0 +1,122 @@ +getMessage()); + exit(1); +} + +$countries = [ + 'Syria' => 'SY', + 'Jordan' => 'JO', + 'Egypt' => 'EG', + 'Iraq' => 'IQ' +]; + +$undercutFactor = 0.92; +$minFloorRatio = 0.85; +$maxCeilRatio = 1.15; + +$kmColumns = [ + 'speedPrice', 'comfortPrice', 'ladyPrice', 'electricPrice', + 'vanPrice', 'deliveryPrice', 'mishwarVipPrice', 'fixedPrice', 'awfarPrice' +]; + +$logEntries = []; + +foreach ($countries as $country => $cc) { + // 1. متوسط سعر الكيلو لكل منافس (آخر 24 ساعة) + $sql = "SELECT competitor_name, AVG(price_per_km) AS avg_ppm + FROM competitor_prices + WHERE country_code = :cc + AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) + AND price_per_km > 0 + GROUP BY competitor_name + ORDER BY avg_ppm ASC"; + $stmt = $con->prepare($sql); + $stmt->execute([':cc' => $cc]); + $competitorAvgs = $stmt->fetchAll(PDO::FETCH_ASSOC); + + if (empty($competitorAvgs)) { + error_log("[AutoAdapt] $country — لا توجد بيانات منافسين"); + continue; + } + + $lowestAvg = (float)$competitorAvgs[0]['avg_ppm']; + + // 2. الأسعار الحالية من kazan + $kazanSql = "SELECT * FROM kazan WHERE country = :country LIMIT 1"; + $stmtK = $con->prepare($kazanSql); + $stmtK->execute([':country' => $country]); + $kazanRow = $stmtK->fetch(PDO::FETCH_ASSOC); + + if (!$kazanRow) { + error_log("[AutoAdapt] $country — لا يوجد صف في kazan"); + continue; + } + + $currentSpeed = (float)$kazanRow['speedPrice']; + $targetSpeed = $lowestAvg * $undercutFactor; + + $minAllowed = $currentSpeed * $minFloorRatio; + $maxAllowed = $currentSpeed * $maxCeilRatio; + + if ($targetSpeed < $minAllowed) { + $targetSpeed = $minAllowed; + } elseif ($targetSpeed > $maxAllowed) { + $targetSpeed = $maxAllowed; + } + + $ratio = $targetSpeed / $currentSpeed; + + $updates = []; + $params = [':country' => $country]; + + foreach ($kmColumns as $col) { + $oldVal = (float)($kazanRow[$col] ?? 0); + if ($oldVal > 0) { + $newVal = round($oldVal * $ratio, 2); + $updates[] = "`$col` = :$col"; + $params[":$col"] = (string)$newVal; + } + } + + if (empty($updates)) { + continue; + } + + $updateSql = "UPDATE kazan SET " . implode(', ', $updates) . " WHERE country = :country"; + $stmtU = $con->prepare($updateSql); + $stmtU->execute($params); + + $logEntries[] = [ + 'country' => $country, + 'code' => $cc, + 'old_speed' => $currentSpeed, + 'new_speed' => $targetSpeed, + 'lowest_competitor' => $lowestAvg, + 'ratio' => round($ratio, 4), + ]; + + error_log("[AutoAdapt] ✅ $country ($cc): speedPrice $currentSpeed → $targetSpeed (ratio: $ratio)"); +} + +if (php_sapi_name() === 'cli') { + echo json_encode(['status' => 'success', 'updates' => $logEntries], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); +} else { + echo json_encode(['status' => 'success', 'updates' => $logEntries]); +} diff --git a/backend/ride/pricing/get.php b/backend/ride/pricing/get.php index 8c2c527..98f38bb 100644 --- a/backend/ride/pricing/get.php +++ b/backend/ride/pricing/get.php @@ -124,6 +124,16 @@ function calculateDynamicPrice($country, $minFare, $distance, $duration, $kazanR $demandCount = (int)$redis->get("demand:grid:" . $grid_id); $availableDrivers = 0; + // Check competitor surge opportunities + $competitorSurgeMultiplier = 1.0; + $surgeOpsJson = $redis->get("surge:opportunities"); + if ($surgeOpsJson) { + $surgeOps = json_decode($surgeOpsJson, true); + if (is_array($surgeOps) && isset($surgeOps[$grid_id])) { + $competitorSurgeMultiplier = (float)$surgeOps[$grid_id]; + } + } + // Driver locations are handled by Location Redis (no prefix) try { if (isset($redisLocation) && $redisLocation !== null) { @@ -139,6 +149,11 @@ function calculateDynamicPrice($country, $minFare, $distance, $duration, $kazanR $surgeMultiplier = min(3.0, $surgeMultiplier); // Cap at 3.0 } } + + // Auto-Adaptive Pricing: Apply competitor surge if it's higher + if ($competitorSurgeMultiplier > $surgeMultiplier) { + $surgeMultiplier = $competitorSurgeMultiplier; + } } catch (Exception $e) {} } diff --git a/backend/schema_primary.sql b/backend/schema_primary.sql index 69c4f0f..7fba399 100644 --- a/backend/schema_primary.sql +++ b/backend/schema_primary.sql @@ -1925,3 +1925,20 @@ CREATE TABLE IF NOT EXISTS `driver_destinations` ( PRIMARY KEY (`id`), KEY `idx_driver_date` (`driver_id`, `usage_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- +-- Table structure for table `market_health_reports` +-- +CREATE TABLE IF NOT EXISTS `market_health_reports` ( + `id` int NOT NULL AUTO_INCREMENT, + `report_date` date NOT NULL, + `country_code` varchar(5) NOT NULL, + `average_pci` decimal(5,2) NOT NULL, + `market_share_percent` decimal(5,2) NOT NULL, + `total_anomalies` int NOT NULL DEFAULT 0, + `total_surge_opportunities` int NOT NULL DEFAULT 0, + `report_data_json` json DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_country_date` (`country_code`, `report_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/knowledge/full_system_simulation_ar.html b/knowledge/full_system_simulation_ar.html new file mode 100644 index 0000000..91675af --- /dev/null +++ b/knowledge/full_system_simulation_ar.html @@ -0,0 +1,1055 @@ + + + + + +محاكاة نظام Siro الكاملة - اليوم 2026-06-21 + + + +
+ +

🚀 محاكاة نظام Siro الكاملة

+

جميع الإضافات والتغييرات التي تم العمل عليها بتاريخ 2026-06-21

+ +
+
+
5
+
Commits رئيسية
+
+
+
74
+
ملف معدل/مضاف
+
+
+
8
+
أنظمة فرعية
+
+
+
2
+
لغات (Dart/Kotlin)
+
+
+ + + + +
+
+ + المرحلة الأولى: Cron Job يولد مهام تسعير المنافسين + backend/bot/generate_price_tasks.php +
+
+ كل 15 دقيقة + | + المناطق: 10 مناطق في دمشق + | + المنافسون: YallaGo • Zaken • Tfadal +
+ +
+
1
+
+
الاتصال بقاعدة البيانات و Redis
+
يتم تحميل boostrap.php لإنشاء اتصال MySQL و Redis
+
+
+ +
+
2
+
+
إنشاء جدول competitor_prices إن لم يكن موجوداً
+
+CREATE TABLE IF NOT EXISTS competitor_prices ( + id INT AUTO_INCREMENT PRIMARY KEY, + competitor_name VARCHAR(50), + from_latitude VARCHAR(30), from_longitude VARCHAR(30), + to_latitude VARCHAR(30), to_longitude VARCHAR(30), + distance_km DECIMAL(8,2), total_price DECIMAL(10,2), + price_per_km DECIMAL(8,2), country_code VARCHAR(5) DEFAULT 'SY', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +
+
+
+ +
+
3
+
+
توليد نقاط عشوائية لـ 10 مناطق رئيسية في دمشق
+
+ ساحة الأمويين + المزة + المالكي + كفرسوسة + الميدان + باب توما + ركن الدين + دمر + برامكة + المهاجرين +
+
لكل منطقة: نقطة بداية عشوائية ضمن 2 كم + رحلة قصيرة (2-5 كم) + رحلة طويلة (10-15 كم)
+
+
+ +
+
4
+
+
إدراج المهام في Redis Queue
+
استخدام LPUSH إلى queue:bot:tasks
+
+// 10 مناطق × رحلتين × 3 منافسين = 60 مهمة كل 15 دقيقة +$redis->lpush('queue:bot:tasks', json_encode($taskData)); +echo "Successfully generated and queued $tasksCreated pricing tasks."; +
+
+
+
+ + + + +
+
+ 🤖 + المرحلة الثانية: Android Bot يسحب المهام وينفذها + android_bot/ +
+ +
+ آلية السحب: Polling كل 15 ثانية + | + الأجهزة المسجلة: SHAM_CASH_BOT_01 • PRICE_SCRAPER_BOT_01 +
+ +
+
+
+
1
+
+
WorkerClient.kt — التوقيع HMAC-SHA256
+
+fun generateSignature(deviceId: String, ts: Long): String { + val message = "$deviceId$ts" + val algorithm = "HmacSHA256" + val mac = Mac.getInstance(algorithm) + val secretKeySpec = SecretKeySpec(SECRET_KEY.toByteArray(), algorithm) + mac.init(secretKeySpec) + val hashBytes = mac.doFinal(message.toByteArray()) + return hashBytes.joinToString("") { "%02x".format(it) } +} +
+
+
+
+
2
+
+
إرسال GET إلى standalone_worker.php
+
+GET /standalone_worker.php?device_id=ANDROID_ID&ts=1718970000&sig=abc123... +→ Response (JSON): +{ + "status": "success", + "has_task": true, + "task": { + "task_id": "prc_667b8e2f", + "type": "price_check", + "app": "com.zakinn.app", + "payload": { + "start_lat": 33.5138, "start_lng": 36.2765, + "end_lat": 33.5350, "end_lng": 36.2950 + } + } +} +
+
+
+
+
+
+
3
+
+
ScraperAccessibilityService.kt — نقر آلي
+
آلة حالة متكاملة لتطبيقات المنافسين:
+
+IDLE → LAUNCHING_APP → SEARCHING_START → SEARCHING_END → READING_PRICE → SUBMITTING +
+
+ YallaGo البحث عن "Where to" أو "أين تريد الذهاب" والنقر عليه +
+ Zakinn استخدام findAccessibilityNodeInfosByViewId للـ TextInput +
+ Tfadal البحث بـ 7 لغات مختلفة (AR/EN/TR) عن حقول الإدخال +
+
+
+
+
4
+
+
قراءة السعر وإرسال النتيجة
+
+// قراءة السعر من النصوص التي تحتوي على SYP, ل.س, AED, SP +searchPriceByCurrency(node) → "ل.س 12,500" +→ submitPriceToServer → POST /standalone_worker.php +{ + "device_id": "...", "ts": 1718970000, "sig": "...", + "task_id": "prc_667b8e2f", "type": "price_check", + "status": "success", + "result_data": { + "app": "com.zakinn.app", + "distance_km": 4.8, + "price": 12500, + "start_lat": 33.5138, "start_lng": 36.2765, + "end_lat": 33.5350, "end_lng": 36.2950 + } +} +
+
+
+
+
+
+ + + + +
+
+ ⚙️ + المرحلة الثالثة: Worker يستقبل النتائج ويخزنها + backend/bot/worker.php +
+ +
+
1
+
+
استقبال POST مع التحقق من HMAC-SHA256
+
// التحقق من التوقيع + منع Replay Attacks (صلاحية 5 دقائق) +if (abs(time() - $ts) > 300) → رفض الطلب +$expected_sig = hash_hmac('sha256', $device_id . $ts, $SECRET_KEY); +hash_equals($expected_sig, $sig) → تحقق آمن من التوقيع
+
+
+
+
2
+
+
تخزين السعر في MySQL + Redis
+
+// MySQL: حفظ البيانات للتقارير اللاحقة +$stmt = $con->prepare("INSERT INTO competitor_prices (...) VALUES (...?)"); +$stmt->execute([$app_name, $start_lat, $start_lng, $end_lat, $end_lng, $distance_km, $price, $pricePerKm, $country_code]); + +// Redis: حفظ آخر 50 سعر لكل تطبيق للتسعير الديناميكي +$redis->lpush("competitor:price_history:$app_name", $pricePerKm); +$redis->ltrim("competitor:price_history:$app_name", 0, 49); +
+
+
+
+ + + + +
+
+ 📊 + المرحلة الرابعة: Standalone Worker Dashboard (واجهة تحكم) + standalone_worker.php +
+
+
1
+
+
واجهة مدير متكاملة (HTML/CSS)
+
نظام ملفات JSON (tasks.json / results.json) — بدون حاجة لقاعدة بيانات
+
+ قائمة المهام المعلقة + سجل النتائج المنجزة + نموذج إضافة مهمة يدوية +
+
+
+
+
2
+
+
عينة من البيانات المعروضة
+ + + + + + + +
#التطبيقالمسارالسعرالحالة
1YallaGoالمزة → مطار دمشق12,500 SYP✅ نجاح
2Zakinnساحة الأمويين → دمر8,200 SYP✅ نجاح
3Tfadalالمالكي → كفرسوسة❌ فشل
4YallaGoبرامكة → المهاجرين15,000 SYP✅ نجاح
5Zakinnالميدان → باب توما6,750 SYP✅ نجاح
+
+
+
+ + + + +
+
+ 🧠 + المرحلة الخامسة: نظام التسويق الذكي بالذكاء الاصطناعي (Gemini AI) + SiroGeminiService.php + trigger_campaign.php +
+ +
+
1
+
+
المدير يضغط "إطلاق حملة استعادة ذكية فوراً"
+
من siro_admin → Marketing Page
+
+// Flutter Controller: marketing_controller.dart +Future triggerAICampaign() async { + var res = await CRUD().post(link: AppLink.triggerCampaign, payload: params); + // → POST /Admin/marketing/trigger_campaign.php +} +
+
+
+ +
+
2
+
+
PHP يسحب آخر أسعار المنافسين ويرسلها إلى Gemini AI
+
+$sqlPrices = "SELECT competitor_name, total_price, distance_km + FROM competitor_prices WHERE country_code = :country + ORDER BY created_at DESC LIMIT 10"; +// البيانات تسحب من جدول competitor_prices الذي ملأه البوت! + +$geminiService = new SiroGeminiService(); +$aiCampaign = $geminiService->analyzeMarketAndDraftCampaign( + $competitorPrices, $siroBasePrice, $regionName, $countryCode +); +
+
+
+ +
+
3
+
+
Gemini AI يحلل ويكتب حملة تسويقية كاملة
+
+// SiroGeminiService.php — إرسال prompt إلى Gemini API +$prompt = " +أنت خبير تسويق ذكي لتطبيق Siro... +1. أسعار المنافسين: " . json_encode($competitorPrices) . " +2. سعر Siro الأساسي: 10000 SYP +الخرج المطلوب JSON: +{ + \"opportunity_detected\": true, + \"recommended_price\": 8500, + \"discount_percentage\": 15, + \"promo_code\": \"SIRODM15\", + \"push_title\": \"🔥 وفر 15% على رحلتك!\", + \"push_body\": \"خصم خاص لسكان دمشق! استخدم كود SIRODM15\", + \"sms_body\": \"اشتقنا لك يا ${passenger_name}! عد إلينا ووفر 15% مع كود SIRODM15\" +}" +
+
+
+ +
+
4
+
+
إرسال الإشعارات والحملات الترويجية
+
+ FCM Push للمستخدمين النشطين (بدون حدود سبام) +
+ WhatsApp للمستخدمين المنقطعين (مع anti-spam 24 ساعة) +
+ SMS كخيار احتياطي إذا فشل WhatsApp +
+
+// trigger_campaign.php +foreach ($targets as $target) { + if ($fcmToken) { + sendFcmNotification($fcmToken, $pushTitle, $pushBody, $fcmData); + $sentFcm++; + } else { + if ($spamCount === 0) { // anti-spam + sendWhatsAppFromServer($decryptedPhone, $smsBody); + $sentWhatsApp++; + } + } +} +
+
+
+ +
+
5
+
+
تسجيل الحملة في سجل التدقيق
+
+// Audit Log +logAudit($con, $user_id, 'trigger_marketing_campaign', 'promos', $promoCode, [ + 'promo_code' => $promoCode, + 'targets_count' => count($dispatchedPassengers) +]); + +// Response للمدير: +{ + "campaign_created": true, + "promo_code": "SIRODM15", + "push_notification": { "sent_count": 34 }, + "whatsapp_sms": { "whatsapp_sent_count": 12, "sms_sent_count": 5 }, + "total_dispatched": 51 +} +
+
+
+
+ + + + +
+
+ 💰 + المرحلة السادسة: محرك التسعير الديناميكي يستخدم بيانات المنافسين + backend/ride/pricing/get.php +
+ +
+
1
+
+
حساب السعر مع الـ Surge Multiplier من Redis
+
+// قراءة الطلب (demand) من Redis الرئيسي +$demandCount = (int)$redis->get("demand:grid:" . $grid_id); + +// قراءة السائقين المتاحين من Redis الموقع +$drivers = $redisLocation->georadius('geo:drivers:available', $lng, $lat, 0.75, 'km'); + +if ($demandCount > 0 && $availableDrivers > 0) { + $surgeRatio = $demandCount / $availableDrivers; + if ($surgeRatio > 1.2) { + $surgeMultiplier = 1.0 + ($surgeRatio - 1.2) * 0.5; // Capped at 3.0 + } +} +
+
+
+ +
+
2
+
+
تطبيق التسعير التنافسي (Competitor Undercut — أقل بـ 8%)
+
+// البحث عن أسعار منافسين لنفس المنطقة الجغرافية +SELECT total_price, distance_km FROM competitor_prices +WHERE country_code = 'SY' + AND (from_latitude + 0.0) BETWEEN :min_flat AND :max_flat + AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) + +// إذا وجدنا تطابق → نخفض سعرنا ليكون أقل من المنافس بـ 8% +if ($competitorTarget !== null) { + $undercutPrice = $competitorTarget * 0.92; // أقل بــ 8% 🎯 + if ($price > $targetAdjustedPrice) { + $price = $targetAdjustedPrice; // تطبيق السعر التنافسي + } +} +
+
+
+ +
+
3
+
+
تطبيق الخصم (Promo Code) + الديون السابقة + التوقيع المشفر
+
+// التحقق من صلاحية الكود الترويجي +$sqlPromo = "SELECT amount FROM promos WHERE promo_code = :code AND ..."; +$discount = (float) $promoData['amount']; + +// إضافة الديون السابقة +$redisDebt = $redisInstance->get("passenger_debt_" . $passenger_id); +$finalPrice += (float) $redisDebt; + +// توقيع السعر بشكل مشفر لمنع التلاعب +$priceToken = $encryptionHelper->encryptData(json_encode([ + 'passenger_id' => $passenger_id, + 'distance' => $distance, 'duration' => $duration, + 'expires' => time() + 420, // 7 دقائق فقط + 'prices' => $pricesRaw +])); +
+
+
+
+ + + + +
+
+ 🔌 + المرحلة السابعة: WebSocket — الاتصال المباشر مع السائقين + socket_intaleq/driver_socket.php +
+ +
+ 🚀 المستوى الثاني (Level 2 Architecture) + | + بورت 2020 WebSocket + | + بورت 2021 HTTP Internal +
+ +
+
1
+
+
اتصال السائق بالـ Socket
+
+// siro_driver → background_service.dart → Socket.IO +socket = IO.io(AppLink.locationSocketUrl, OptionBuilder() + .setQuery({'driver_id': driverId, 'token': token})); + +// server side: driver_socket.php +$socket->join('driver_' . $driverId); +$connectedDrivers[$driverId] = ['conn' => $socket, 'platform' => $platform, 'token' => $token]; +
+
+
+ +
+
2
+
+
تحديث الموقع مع Redis Pipeline Buffering (Level 2)
+
+// السائق يرسل موقعه ← يتم تجميع الأحداث كل 500ms +$socket->on('update_location', function($data) use (&$eventBuffer) { + // حساب التغيير الفعال (تجنب عمليات Redis غير الضرورية) + if (!$didMove && !$speedChanged && !$headingChanged && !$statusChanged) return; + + // التخزين المؤقت → Redis Pipeline + $eventBuffer[$driverId] = [ + 'hmset' => ['heading' => $heading, 'speed' => $speed, 'status' => $status], + 'geoadd' => ['status' => $status, 'lng' => $lng, 'lat' => $lat], + 'status_change' => ['old' => $oldStatus, 'new' => $newStatus] + ]; +}); + +// Timer كل 500ms: تنفيذ Pipeline +Timer::add(0.5, function() { + $pipe = $redis->pipeline(); + foreach ($eventBuffer as $driverId => $ops) { /* تنفيذ مجمع */ } + $pipe->execute(); + $eventBuffer = []; +}); +
+
+ تحسين أداء بدلاً من 3 عمليات Redis لكل تحديث → عملية واحدة كل 500ms لكل السائقين +
+
+
+ +
+
3
+
+
إرسال الطلبات الجديدة (Dispatch + Market)
+
+// HTTP Internal → dispatch_order +$io->to('driver_' . $driverId)->emit('new_ride_request', $payload); +if ($platform === 'ios') sendFCM_Async($token, 'طلب جديد', 'لديك رحلة جديدة'); + +// Market New Ride +$redis->geoadd('geo:rides:waiting', $lng, $lat, $rideId); +$nearbyDrivers = $redis->georadius('geo:drivers:available', $lng, $lat, 50, 'km'); +foreach ($nearbyDrivers as $driverId) { + $io->to('driver_' . $driverId)->emit('market_new_ride', $payload); +} +
+
+
+ +
+
4
+
+
تحديث موقع السائق إلى الراكب (Forward to Passenger Socket)
+
+// Throttle: إرسال الموقع للراكب فقط إذا تحرك >15 متر أو مر >3 ثواني +function forwardLocationToPassengerSocket(...) { + if ($dist < FORWARD_MIN_METERS && $timeDiff < FORWARD_MAX_SECONDS) return; + + $http = new AsyncHttp(); + $http->request($passengerSocketUrl, ['method' => 'POST', 'data' => $payload, + 'headers' => ['x-internal-key' => $internalKey]]); +} +
+
+
+
+ + + + +
+
+ 📍 + المرحلة الثامنة: تطبيق السائق — الوجهة المقترحة والخلفية + siro_driver/ +
+ +
+
+
+
1
+
+
DestinationController — إدارة الوجهة
+
+// حفظ الوجهة (حد أقصى مرتين في اليوم) +Future saveDestination(LatLng position, String name) async { + final response = await CRUD().post(link: AppLink.saveDriverDestination, payload: { + 'action': 'set', + 'destination_lat': position.latitude.toString(), + 'destination_lng': position.longitude.toString(), + 'destination_name': name, + }); + // تحقق من رسالة "الحد الأقصى لتعديل الوجهة" + if (msg.contains("الحد الأقصى") || msg.contains("limit")) { + mySnackbarWarning('You have reached the daily limit'); + } +} + +// جلب الوجهة الحالية + مسحها +fetchActiveDestination() / clearDestination() +
+
+
+
+
+
+
2
+
+
Background Service — استقبال الطلبات في الخلفية
+
+// Socket في الخلفية ← عند وصول new_ride_request +socket.on('new_ride_request', (data) async { + if (isAppInForeground || overlayActive) return; // لا نكرر + + // أندرويد: نعرض Overlay فوق أي تطبيق + await FlutterOverlayWindow.showOverlay( + overlayTitle: "طلب جديد 🚖", + overlayContent: "لديك طلب رحلة جديد!", + flag: OverlayFlag.focusPointer, + ); + + // iOS: نعرض Local Notification + flutterLocalNotificationsPlugin.show(1002, "طلب رحلة جديد 🚖", ...); +}); +
+
+
+
+
+
+ + + + +
+
+ 📱 + المرحلة التاسعة: Admin App — لوحة التسويق الذكي + siro_admin/ +
+ +
+
1
+
+
MarketingController — التحكم بالأسواق والحملات
+
يدير كل من: + Anomalies + Campaigns Log + Telemetry + Price Comparison + What-If Simulator +
+
+// دوال رئيسية: +fetchAnomalies() // شواذ الأسعار +fetchCampaignsLog() // سجل الحملات +fetchTelemetry() // استهلاك API والتكاليف +fetchPriceComparison() // مقارنة الأسعار + PCI +fetchPriceGapHeatmap() // خارطة الفجوات السعرية +fetchMarketShareAnalytics() // تطور الحصة السوقية +fetchAiPricePrediction() // توقعات AI +fetchWinbackTargets() // أهداف الاستعادة +triggerAICampaign() // إطلاق حملة AI +runWhatIfSimulation(price) // محاكي تغيير الأسعار +
+
+
+ +
+
2
+
+
واجهة MarketingPage متكاملة بـ 3 تبويبات
+
+ التبويب 1: شواذ الأسعار والمنافسين
+ - بطاقة التحكم بالـ AI (Gemini)
+ - توقعات الذكاء الاصطناعي (Siro AI Prediction)
+ - محاكي تغيير الأسعار الذكي (What-If Simulator)
+ - رسم بياني لتطور الحصة السوقية (آخر 12 أسبوع)
+ - رسم بياني لمقارنة تقلبات الأسعار اللحظية
+ - خارطة الفجوات ومؤشر التنافسية السعري (PCI)
+ - أهداف استعادة العملاء (Win-Back) +

+ التبويب 2: سجل الحملات المنجزة
+ - عرض الحملات المرسلة (Push / WhatsApp / SMS) +

+ التبويب 3: إعدادات الأتمتة والتحكم
+ - مراقب استهلاك الرموز API Telemetry
+ - تفعيل/تعطيل الـ Autopilot
+ - تعديل System Prompt للذكاء الاصطناعي +
+
+
+ +
+
3
+
+
What-If Simulator — محاكي السيناريوهات
+
+// مثلاً: المدير يقترح سعر 8500 لكل كم +POST /Admin/marketing/what_if_simulator.php?speed_price=8500&country_code=SY +→ Response: +{ + "simulated_pci": 0.89, + "market_share_percent": 72.5, + "recommendation_status": "success", + "recommendation_message": "✅ السعر المقترح 8500 SYP/كم سيجعلنا أرخص من جميع المنافسين!" +} +
+
+
+
+ + + + +
+
+ 🗣️ + المرحلة العاشرة: تطبيق الراكب — الملاحة والـ TTS + siro_rider/ +
+ +
+
1
+
+
NavigationView + NavigationController — الملاحة
+
واجهة ملاحة كاملة مع: معلومات المسار، وقت الوصول، المسافة المتبقية
+
+
+
+
2
+
+
TTS (Text-to-Speech) Controller
+
تحويل إرشادات الملاحة إلى صوت بالعربية — إعلانات صوتية للمنعطفات
+
+// siro_rider/lib/controller/functions/tts.dart +class TextToSpeechController extends GetxController { + Future speakText(String text) async { + await flutterTts.setLanguage("ar-SA"); + await flutterTts.setSpeechRate(0.5); + await flutterTts.speak(text); + } +} +
+
+
+
+ + + + +
+
+ 🔁 + مخطط تدفق البيانات الكامل — دورة متكاملة +
+ +
+ ⏰ Cron Job (15 دقيقة)
+ ↓ يولد
+ 📋 60 مهمة تسعير → Redis Queue
+ ↓ يسحب
+ 🤖 Android Bot (Accessibility Service)
+ ↓ يفتح التطبيق وينقر آلياً ويقرأ
+ 📱 YallaGo • Zakinn • Tfadal
+ ↓ يرسل النتائج
+ 📊 Worker → MySQL (competitor_prices) + Redis
+ ↓ يستخدم في
+ 💰 محرك التسعير الديناميكي (Undercut 8%)
+ ↓ ويستخدم في
+ 🧠 Gemini AI Campaign Generator
+ ↓ يرسل
+ 📣 FCM Push + WhatsApp + SMS → الركاب
+ ↓ وأيضاً
+ 📊 Admin Dashboard يعرض التحليلات
+
+
+ + + + +
+
+ 📁 + هيكلية الملفات المضافة والمعدلة اليوم +
+
+
+
📱 siro_driver/
+
+ ├── lib/constant/links.dart
+ ├── lib/controller/functions/background_service.dart
+ ├── lib/controller/functions/location_controller.dart
+ ├── lib/controller/home/captin/
+ │ ├── destination_controller.dart
+ │ ├── home_captain_controller.dart
+ │ └── order_request_controller.dart
+ ├── lib/controller/local/ar_eg.dart
+ ├── lib/controller/local/ar_jo.dart
+ ├── lib/controller/local/ar_sy.dart
+ ├── lib/controller/local/en.dart
+ ├── lib/views/home/Captin/home_captain/
+ │ ├── home_captin.dart
+ │ └── widget/destination_bottom_sheet.dart
+ └── lib/views/home/Captin/mapDriverWidgets/passenger_info_window.dart +
+
+
+
⚙️ backend/
+
+ ├── bot/
+ │ ├── generate_price_tasks.php (Cron Job)
+ │ ├── worker.php (Redis Worker Endpoint)
+ │ └── standalone_worker.php (Dashboard)
+ ├── core/Services/SiroGeminiService.php
+ ├── Admin/marketing/
+ │ ├── trigger_campaign.php
+ │ ├── get_price_comparison.php
+ │ ├── get_market_anomalies.php
+ │ ├── get_telemetry.php
+ │ └── get_campaigns_log.php
+ ├── ride/pricing/get.php
+ ├── ride/location/save_driver_destination.php
+ └── ride/rides/add_ride.php, acceptRide.php, cancel*.php +
+
🤖 android_bot/
+
+ ├── service/ScraperAccessibilityService.kt
+ ├── service/AppLauncher.kt
+ ├── network/WorkerClient.kt
+ └── MainActivity.kt +
+
+
+
+ + + + +
+
+ + الخلاصة — ماذا أنجزنا اليوم؟ +
+
+ مكتمل Android Bot — نقر آلي ذكي (Accessibility Service) مع آلة حالة متكاملة لـ 3 تطبيقات منافسة
+ مكتمل Cron Job — توليد 60 مهمة تسعير كل 15 دقيقة لـ 10 مناطق في دمشق
+ مكتمل Worker Endpoint — استقبال نتائج البوت مع HMAC Authentication + MySQL/Redis
+ مكتمل Gemini AI — خدمة تحليل أسعار وصياغة حملات تسويقية بالعربية (لهجات محلية)
+ مكتمل Marketing Campaign — إطلاق حملات عبر FCM + WhatsApp + SMS مع Anti-Spam
+ مكتمل Pricing Engine — تسعير ديناميكي مع Undercut 8% عن المنافسين + Surge Pricing
+ مكتمل WebSocket Level 2 — Redis Pipeline Buffering كل 500ms + Forward للموقع
+ مكتمل Destination System — وجهة السائق مع حد يومي + خلفية مع Overlay للطلبات
+ مكتمل Admin Dashboard — لوحة تسويق ذكية مع PCI, Heatmap, What-If Simulator, Telemetry
+ مكتمل Standalone Dashboard — واجهة تحكم كاملة للبوت بدون حاجة Redis/MySQL +
+
+ +
+ + diff --git a/siro_admin/lib/constant/links.dart b/siro_admin/lib/constant/links.dart index ce89d85..3a47b36 100644 --- a/siro_admin/lib/constant/links.dart +++ b/siro_admin/lib/constant/links.dart @@ -345,6 +345,11 @@ import 'box_name.dart';class AppLink { static String getCampaignsLog = "$server/Admin/marketing/get_campaigns_log.php"; static String getTelemetry = "$server/Admin/marketing/get_telemetry.php"; static String getPriceComparison = "$server/Admin/marketing/get_price_comparison.php"; + static String whatIfSimulator = "$server/Admin/marketing/what_if_simulator.php"; + static String getPriceGapHeatmap = "$server/Admin/marketing/get_price_gap_heatmap.php"; + static String winbackHotspotTargets = "$server/Admin/marketing/winback_hotspot_targets.php"; + static String getMarketShareAnalytics = "$server/Admin/marketing/get_market_share_analytics.php"; + static String aiPricePrediction = "$server/Admin/marketing/ai_price_prediction.php"; static String saveDriverDestination = "$server/ride/location/save_driver_destination.php"; static String paymentServerV2 = 'https://walletintaleq.intaleq.xyz/v2/main'; static String realtimeDashboardV2 = "$server/Admin/v2/realtime_dashboard.php"; diff --git a/siro_admin/lib/controller/admin/marketing_controller.dart b/siro_admin/lib/controller/admin/marketing_controller.dart index f9fc92c..34ffd7e 100644 --- a/siro_admin/lib/controller/admin/marketing_controller.dart +++ b/siro_admin/lib/controller/admin/marketing_controller.dart @@ -28,6 +28,10 @@ class MarketingController extends GetxController { fetchAnomalies(); fetchCampaignsLog(); fetchPriceComparison(); + fetchPriceGapHeatmap(); + fetchMarketShareAnalytics(); + fetchAiPricePrediction(); + fetchWinbackTargets(); } // --- Autopilot Status Toggle --- @@ -187,4 +191,158 @@ class MarketingController extends GetxController { update(); } } + + // --- What-If Simulator State --- + double? simulatedPci; + double? simulatedMarketShare; + String? simulatorRecommendationStatus; + String? simulatorRecommendationMessage; + + Future runWhatIfSimulation(String proposedSpeedPrice) async { + isLoading = true; + update(); + try { + Map params = { + 'speed_price': proposedSpeedPrice + }; + if (selectedCountry != 'All') { + params['country_code'] = selectedCountry; + } else { + params['country_code'] = 'SY'; // Fallback + } + + var res = await CRUD().post( + link: AppLink.whatIfSimulator, + payload: params, + ); + if (res is Map && res['status'] == 'success') { + final data = res['message']; + if (data is Map) { + simulatedPci = (data['simulated_pci'] ?? 0.0).toDouble(); + simulatedMarketShare = (data['simulated_market_share_percent'] ?? 0.0).toDouble(); + simulatorRecommendationStatus = data['recommendation_status']; + simulatorRecommendationMessage = data['recommendation_message']; + } + } else { + Get.snackbar("Simulation Error", res['message'] ?? "Failed to run simulation"); + } + } catch (e) { + Get.snackbar("Error", "Network error during simulation"); + } finally { + isLoading = false; + update(); + } + } + + // --- Heatmap State --- + List heatmapData = []; + double currentSiroPriceHeatmap = 0.0; + + Future fetchPriceGapHeatmap() async { + try { + Map params = {}; + if (selectedCountry != 'All') { + params['country_code'] = selectedCountry; + } else { + params['country_code'] = 'SY'; // Fallback + } + + var res = await CRUD().post( + link: AppLink.getPriceGapHeatmap, + payload: params, + ); + if (res is Map && res['status'] == 'success') { + final data = res['message']; + if (data is Map) { + heatmapData = data['heatmap_data'] ?? []; + currentSiroPriceHeatmap = (data['current_siro_price_per_km'] ?? 0.0).toDouble(); + update(); + } + } + } catch (e) { + // Silently fail + } + } + + // --- Market Share Analytics --- + List marketShareData = []; + Future fetchMarketShareAnalytics() async { + try { + Map params = {}; + if (selectedCountry != 'All') { + params['country_code'] = selectedCountry; + } else { + params['country_code'] = 'SY'; + } + + var res = await CRUD().post( + link: AppLink.getMarketShareAnalytics, + payload: params, + ); + if (res is Map && res['status'] == 'success') { + marketShareData = res['historical_data'] ?? []; + update(); + } + } catch (e) { + // Ignore + } + } + + // --- AI Price Prediction --- + String? aiPredictionMessage; + List predictedSurgeHours = []; + Future fetchAiPricePrediction() async { + try { + Map params = {}; + if (selectedCountry != 'All') { + params['country_code'] = selectedCountry; + } else { + params['country_code'] = 'SY'; + } + + var res = await CRUD().post( + link: AppLink.aiPricePrediction, + payload: params, + ); + if (res is Map && res['status'] == 'success') { + aiPredictionMessage = res['ai_analysis_message']; + predictedSurgeHours = res['predicted_surge_hours'] ?? []; + update(); + } + } catch (e) { + // Ignore + } + } + + // --- Win-Back Targets --- + List winbackTargets = []; + int winbackTotalCount = 0; + Future fetchWinbackTargets() async { + try { + Map params = {}; + if (selectedCountry != 'All') { + params['country_code'] = selectedCountry; + } else { + params['country_code'] = 'SY'; + } + + var res = await CRUD().post( + link: AppLink.winbackHotspotTargets, + payload: params, + ); + if (res is Map && res['status'] == 'success') { + winbackTargets = res['targets'] ?? []; + winbackTotalCount = res['total_targets'] ?? 0; + update(); + } + } catch (e) { + // Ignore + } + } + + @override + void onInit() { + super.onInit(); + // Initially fetch these too if needed, but fetch them specifically when country changes + } } diff --git a/siro_admin/lib/views/admin/marketing/marketing_page.dart b/siro_admin/lib/views/admin/marketing/marketing_page.dart index c613e28..ac9aafd 100644 --- a/siro_admin/lib/views/admin/marketing/marketing_page.dart +++ b/siro_admin/lib/views/admin/marketing/marketing_page.dart @@ -27,6 +27,7 @@ class MarketingPage extends StatelessWidget { controller.fetchCampaignsLog(); controller.fetchTelemetry(); controller.fetchPriceComparison(); + controller.fetchPriceGapHeatmap(); return GetBuilder( builder: (c) { @@ -124,6 +125,9 @@ class MarketingPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildAIControlCard(context, c), + _buildAIPredictionCard(context, c), + _buildWhatIfSimulatorCard(context, c), + _buildMarketShareChart(context, c), _buildComparisonChart(), _buildPCIHeatmapSection(), Padding( @@ -386,33 +390,28 @@ class MarketingPage extends StatelessWidget { Widget _buildPCIHeatmapSection() { return GetBuilder( builder: (c) { - final pciData = c.pciRegions; - final siroBase = c.siroBasePrices; - final double siroSpeedPrice = (siroBase['speedPrice'] != null) - ? double.tryParse(siroBase['speedPrice'].toString()) ?? 0.0 - : 0.0; + final heatmapList = c.heatmapData; + final siroSpeedPrice = c.currentSiroPriceHeatmap; List> regions = []; - if (pciData is List && pciData.isNotEmpty) { - for (final item in pciData) { - final double compAvg = double.tryParse((item['avg_price_per_km'] ?? 0).toString()) ?? 0.0; - final pci = siroSpeedPrice > 0 && compAvg > 0 - ? (siroSpeedPrice / compAvg).clamp(0.5, 1.5) - : 1.0; + if (heatmapList is List && heatmapList.isNotEmpty) { + for (final item in heatmapList) { + final double pci = double.tryParse(item['pci'].toString()) ?? 1.0; final pct = ((1 - pci) * 100).abs().toStringAsFixed(1); final isCheaper = pci < 1; regions.add({ - 'name': '${item['competitor_name'] ?? 'منافس'} (${item['lat_group'] ?? ''}, ${item['lng_group'] ?? ''})', + 'name': 'شبكة (${item['lat']}, ${item['lng']})', 'pci': pci, - 'desc': isCheaper ? 'أرخص بنسبة $pct% من المنافسين' : 'أغلى بنسبة $pct% من المنافسين', - 'samples': item['samples'] ?? 0, + 'weight': item['weight'] ?? 0.0, + 'desc': isCheaper ? 'أرخص بـ $pct%' : 'أغلى بـ $pct%', + 'samples': item['sample_size'] ?? 0, }); } } if (regions.isEmpty) { regions = [ - {'name': 'لا توجد بيانات', 'pci': 1.0, 'desc': 'يتم جمع البيانات خلال 24 ساعة', 'samples': 0}, + {'name': 'لا توجد بيانات حرارية كافية', 'pci': 1.0, 'weight': 0.0, 'desc': 'يتم جمع البيانات حالياً', 'samples': 0}, ]; } @@ -439,6 +438,8 @@ class MarketingPage extends StatelessWidget { padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -447,16 +448,16 @@ class MarketingPage extends StatelessWidget { child: Text(region['name'] as String, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: _textPrimary)), ), const SizedBox(width: 8), - Text(region['desc'] as String, style: const TextStyle(fontSize: 10, color: _success)), + Text(region['desc'] as String, style: TextStyle(fontSize: 10, color: (region['pci'] as double) < 1 ? _success : _danger)), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( - value: (1 - pciVal).clamp(0.0, 1.0), - backgroundColor: AppColor.surfaceElevated, - color: _success, + value: ((region['weight'] as double) + 1) / 2, // Map -1..1 to 0..1 + backgroundColor: _success.withOpacity(0.3), + color: (region['pci'] as double) < 1 ? _success : _danger, minHeight: 6, ), ), @@ -472,24 +473,104 @@ class MarketingPage extends StatelessWidget { ); } + Widget _buildWhatIfSimulatorCard(BuildContext context, MarketingController c) { + final TextEditingController priceCtrl = TextEditingController(); + return Card( + color: _surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: _divider)), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.science_outlined, color: _accent, size: 20), + SizedBox(width: 8), + Text('محاكي تغيير الأسعار الذكي', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: _textPrimary)), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: priceCtrl, + keyboardType: TextInputType.number, + style: const TextStyle(color: _textPrimary, fontSize: 13), + decoration: InputDecoration( + hintText: 'سعر الكيلومتر المقترح', + hintStyle: const TextStyle(color: _textSecondary, fontSize: 12), + filled: true, + fillColor: AppColor.surfaceElevated, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () { + if (priceCtrl.text.isNotEmpty) c.runWhatIfSimulation(priceCtrl.text); + }, + style: ElevatedButton.styleFrom( + backgroundColor: _accent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('محاكاة', style: TextStyle(fontSize: 12)), + ), + ], + ), + if (c.simulatedPci != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: c.simulatorRecommendationStatus == 'success' ? _success.withOpacity(0.1) : (c.simulatorRecommendationStatus == 'danger' ? _danger.withOpacity(0.1) : _info.withOpacity(0.1)), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('مؤشر PCI المتوقع:', style: TextStyle(fontSize: 12, color: _textSecondary)), + Text(c.simulatedPci.toString(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: _textPrimary)), + ], + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('الحصة السوقية (أرخص):', style: TextStyle(fontSize: 12, color: _textSecondary)), + Text('${c.simulatedMarketShare}%', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: _textPrimary)), + ], + ), + const SizedBox(height: 8), + Text( + c.simulatorRecommendationMessage ?? '', + style: TextStyle( + fontSize: 11, + color: c.simulatorRecommendationStatus == 'success' ? _success : (c.simulatorRecommendationStatus == 'danger' ? _danger : _info) + ), + ), + ], + ), + ), + ] + ], + ), + ), + ); + } + Widget _buildLogsTab(BuildContext context, MarketingController c) { if (c.isLoading && c.campaignsLog.isEmpty) { return const Center(child: CircularProgressIndicator(color: _accent)); } - if (c.campaignsLog.isEmpty) { - return const Center( - child: Text('سجل الحملات فارغ حالياً.', style: TextStyle(color: _textSecondary)), - ); - } - - return ListView.builder( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(16), - itemCount: c.campaignsLog.length, - itemBuilder: (context, index) { - final log = c.campaignsLog[index]; - final channel = log['channel']?.toString().toUpperCase() ?? "FCM"; Color channelColor = _info; if (channel == 'SMS') channelColor = _success; if (channel == 'WHATSAPP') channelColor = const Color(0xFF25D366); @@ -807,4 +888,150 @@ class MarketingPage extends StatelessWidget { ), ); } + + Widget _buildAIPredictionCard(BuildContext context, MarketingController c) { + if (c.aiPredictionMessage == null) return const SizedBox.shrink(); + + return Card( + color: _info.withOpacity(0.05), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: _info.withOpacity(0.3))), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.psychology, color: _info, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('توقعات الذكاء الاصطناعي (Siro AI Prediction)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: _info)), + const SizedBox(height: 6), + Text( + c.aiPredictionMessage!, + style: const TextStyle(fontSize: 11, color: _textPrimary, height: 1.5), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildMarketShareChart(BuildContext context, MarketingController c) { + if (c.marketShareData.isEmpty) return const SizedBox.shrink(); + + return Card( + color: _surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: _divider)), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'تطور الحصة السوقية (آخر 12 أسبوع)', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: _textPrimary), + ), + const SizedBox(height: 16), + SizedBox( + height: 180, + child: LineChart( + LineChartData( + gridData: const FlGridData(show: false), + titlesData: const FlTitlesData( + leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: true, reservedSize: 30)), + bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: c.marketShareData.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), double.tryParse(e.value['market_share'].toString()) ?? 0.0); + }).toList(), + isCurved: true, + color: _accent, + barWidth: 3, + isStrokeCapRound: true, + dotData: const FlDotData(show: true), + belowBarData: BarAreaData(show: true, color: _accent.withOpacity(0.1)), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildWinbackCampaignsSection(BuildContext context, MarketingController c) { + return Card( + color: _surface, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: const BorderSide(color: _divider)), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.radar, color: _accent, size: 24), + SizedBox(width: 8), + Text('حملات استعادة العملاء (Win-Back)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: _textPrimary)), + ], + ), + const SizedBox(height: 8), + const Text( + 'يتم البحث عن الركاب المنقطعين عن التطبيق والذين يتواجدون حالياً بالقرب من مناطق تشهد أسعار ذروة لدى المنافسين.', + style: TextStyle(color: _textSecondary, fontSize: 11), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => c.fetchWinbackTargets(), + icon: const Icon(Icons.person_search, size: 18), + label: const Text('بحث عن أهداف حالية'), + style: ElevatedButton.styleFrom( + backgroundColor: _accent.withOpacity(0.1), + foregroundColor: _accent, + elevation: 0, + ), + ), + ), + if (c.winbackTotalCount > 0) ...[ + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + // TODO: Implement trigger specific winback campaign + Get.snackbar("Campaign Triggered", "SMS sent to ${c.winbackTotalCount} targets"); + }, + icon: const Icon(Icons.send), + label: Text('إرسال SMS لـ ${c.winbackTotalCount}'), + style: ElevatedButton.styleFrom( + backgroundColor: _success, + foregroundColor: Colors.white, + ), + ), + ), + ] + ], + ), + ], + ), + ), + ); + } } diff --git a/siro_driver/lib/constant/links.dart b/siro_driver/lib/constant/links.dart index 341f2d7..9ea0d01 100755 --- a/siro_driver/lib/constant/links.dart +++ b/siro_driver/lib/constant/links.dart @@ -109,6 +109,7 @@ class AppLink { default: return 'https://ride-jordan.siromove.com/siro/ride'; } } + static String get getSurgeHeatmap => "$server/ride/heatmap/get_surge_heatmap.php"; ///mapOSM = 'https://routesy.intaleq.xyz' static String get mapOSM {