diff --git a/backend/Admin/v2/analytics/dashboard/index.php b/backend/Admin/v2/analytics/dashboard/index.php new file mode 100644 index 00000000..a20bfa54 --- /dev/null +++ b/backend/Admin/v2/analytics/dashboard/index.php @@ -0,0 +1,1115 @@ + + + + + + + Siro Analytics Dashboard + + + + + + + +
+

Siro Analytics Dashboard

+
+ + +
+
+ +
+
الخريطة التفاعلية
+
السوق والتسعير
+
الأسطول والجودة
+
النمو والاحتفاظ
+
+ + + + +
+
+ +
خريطة فجوة العرض والطلب
+
+ + + + + +
+
+ +
+
+

توزيع الرحلات حسب الساعة (آخر 7 أيام)

+ +
+
+

قمع الرحلات (حالات الطلبات)

+ +
+
+
+ + + + +
+
+ +
+
+

أسعار المنافسين (آخر 24 ساعة)

+ +
+
+

مؤشر تنافسية الأسعار (PCI) - تاريخي

+ +
+
+ +
+
+

الحصة السوقية الأسبوعية

+ +
+
+

الانحرافات والفرص (Anomalies)

+ +
+
+
+ + + + +
+
+ +
الشكاوى المفتوحة حسب النوع
+
+
+

توزيع الشكاوى

+ +
+
+

الإيرادات اليومية vs أرباح الشركة

+ +
+
+ +
أعلى المناطق نشاطاً (آخر 4 أسابيع)
+
+ + + + + + + + + + +
#الموقع (Lat, Lng)عدد الرحلاتمتوسط السعر
+
+
+ + + + +
+
+ +
+
+

نمو المسجّلين الجدد (30 يوم)

+ +
+
+

مقارنة أسبوع بأسبوع

+ +
+
+ +
+
+

احتفاظ الركاب (Retention Cohort)

+ +
+
+

الإيرادات اليومية (30 يوم)

+ +
+
+
+ + + + + diff --git a/backend/Admin/v2/analytics/dashboard_data.php b/backend/Admin/v2/analytics/dashboard_data.php new file mode 100644 index 00000000..e5cae924 --- /dev/null +++ b/backend/Admin/v2/analytics/dashboard_data.php @@ -0,0 +1,119 @@ + 'error', 'message' => 'Unauthorized']); + exit; +} + +$requestedDate = filterRequest('date') ?: date('Y-m-d'); +$section = filterRequest('section') ?: 'all'; + +if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $requestedDate)) { + http_response_code(400); + echo json_encode(['status' => 'error', 'message' => 'Invalid date format']); + exit; +} + +$cacheBase = __DIR__ . '/../../../cache/analytics'; +$dayDir = "$cacheBase/$requestedDate"; + +$response = [ + 'status' => 'success', + 'date' => $requestedDate, + 'section' => $section, + 'data' => [], +]; + +function loadSnapshot(string $dir, string $name): ?array { + $path = "$dir/$name.json"; + if (!file_exists($path)) return null; + $data = json_decode(file_get_contents($path), true); + return is_array($data) ? $data : null; +} + +function loadLatestRealtime(string $dir): ?array { + $files = glob("$dir/realtime_*.json"); + if (empty($files)) return null; + sort($files); + $latest = end($files); + $data = json_decode(file_get_contents($latest), true); + return is_array($data) ? $data : null; +} + +$sectionMap = [ + 'realtime' => fn() => loadLatestRealtime($dayDir), + 'gap' => fn() => loadSnapshot($dayDir, 'supply_demand_gap'), + 'heatmap' => fn() => loadSnapshot($dayDir, 'heatmap'), + 'pricing' => fn() => loadSnapshot($dayDir, 'pricing_grids'), + 'demand' => fn() => loadSnapshot($dayDir, 'predictive_demand'), + 'revenue' => fn() => loadSnapshot($dayDir, 'revenue_30d'), + 'growth' => fn() => loadSnapshot($dayDir, 'growth_30d'), + 'market' => fn() => loadSnapshot($dayDir, 'market_health'), + 'complaints' => fn() => loadSnapshot($dayDir, 'complaints_open'), + 'funnel' => fn() => loadSnapshot($dayDir, 'ride_funnel'), + 'hourly' => fn() => loadSnapshot($dayDir, 'hourly_pattern'), + 'weekly' => fn() => loadSnapshot($dayDir, 'weekly_comparison'), + 'zones' => fn() => loadSnapshot($dayDir, 'top_zones'), + 'retention' => fn() => loadSnapshot($dayDir, 'retention_cohort'), + 'competitor' => fn() => loadSnapshot($dayDir, 'competitor_prices_24h'), +]; + +try { + if (!is_dir($dayDir)) { + $response['data'] = null; + $response['note'] = "No snapshot data for $requestedDate"; + + $indexPath = "$cacheBase/index.json"; + if (file_exists($indexPath)) { + $idx = json_decode(file_get_contents($indexPath), true); + $response['available_dates'] = $idx['available_dates'] ?? []; + } + + echo json_encode($response, JSON_UNESCAPED_UNICODE); + exit; + } + + if ($section === 'all') { + foreach ($sectionMap as $key => $loader) { + $result = $loader(); + if ($result !== null) { + $response['data'][$key] = $result; + } + } + } elseif (isset($sectionMap[$section])) { + $response['data'] = $sectionMap[$section](); + } else { + http_response_code(400); + echo json_encode([ + 'status' => 'error', + 'message' => "Unknown section: $section", + 'available' => array_keys($sectionMap), + ]); + exit; + } + + $indexPath = "$cacheBase/index.json"; + if (file_exists($indexPath)) { + $idx = json_decode(file_get_contents($indexPath), true); + $response['available_dates'] = $idx['available_dates'] ?? []; + } + + echo json_encode($response, JSON_UNESCAPED_UNICODE); + +} catch (Exception $e) { + http_response_code(500); + error_log("[dashboard_data.php] " . $e->getMessage()); + echo json_encode(['status' => 'error', 'message' => 'Internal error']); +} diff --git a/backend/bot/cron_dashboard_snapshot.php b/backend/bot/cron_dashboard_snapshot.php new file mode 100644 index 00000000..2970553d --- /dev/null +++ b/backend/bot/cron_dashboard_snapshot.php @@ -0,0 +1,350 @@ +getMessage() . "\n"); +} + +echo "[DashboardSnapshot] Mode: $mode | $now\n"; + +// ───────────────────────────────────────────────────────────── +// الوظائف المساعدة +// ───────────────────────────────────────────────────────────── +function saveSnapshot(string $filename, array $data, string $dir): void { + $data['_generated_at'] = date('Y-m-d H:i:s'); + $data['_mode'] = $GLOBALS['mode']; + $path = "$dir/$filename"; + file_put_contents($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + echo " ✓ Saved: $path\n"; +} + +function safeQuery(PDO $db, string $sql, array $params = []): array { + $stmt = $db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchAll(PDO::FETCH_ASSOC); +} + +function safeScalar(PDO $db, string $sql, array $params = []) { + $stmt = $db->prepare($sql); + $stmt->execute($params); + return $stmt->fetchColumn(); +} + +// ───────────────────────────────────────────────────────────── +// 1. لقطة اللحظة الحالية (كل ساعة) +// ───────────────────────────────────────────────────────────── +if (in_array($mode, ['hourly', 'daily', 'weekly'])) { + echo "[Snapshot] Realtime stats...\n"; + + $activeRides = (int)safeScalar($conRide, "SELECT COUNT(*) FROM ride WHERE status IN ('wait','started','arrived')"); + $onlineDrivers = (int)safeScalar($con, "SELECT COUNT(*) FROM car_locations WHERE status = 'on'"); + $revenueToday = (float)safeScalar($conRide, "SELECT IFNULL(SUM(price_for_passenger),0) FROM ride WHERE status='Finished' AND DATE(created_at)=CURDATE()"); + $revenueYesterday = (float)safeScalar($conRide, "SELECT IFNULL(SUM(price_for_passenger),0) FROM ride WHERE status='Finished' AND DATE(created_at)=DATE_SUB(CURDATE(), INTERVAL 1 DAY)"); + $openComplaints = (int)safeScalar($con, "SELECT COUNT(*) FROM complaint WHERE statusComplaint='Open'"); + $expiringLicenses = (int)safeScalar($con, "SELECT COUNT(*) FROM driver WHERE expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 15 DAY)"); + + $hour = date('H'); + saveSnapshot("realtime_{$hour}.json", [ + 'active_rides' => $activeRides, + 'online_drivers' => $onlineDrivers, + 'revenue_today' => $revenueToday, + 'revenue_yesterday' => $revenueYesterday, + 'open_complaints' => $openComplaints, + 'expiring_licenses' => $expiringLicenses, + ], $todayDir); + + // ─── فجوة العرض والطلب (Supply-Demand Gap) ─── + echo "[Snapshot] Supply-Demand gap...\n"; + + $GRID = 0.01; + $demandRaw = safeQuery($conRide, " + SELECT + ROUND(SUBSTRING_INDEX(start_location,',',1) / $GRID) * $GRID AS lat, + ROUND(SUBSTRING_INDEX(start_location,',',-1) / $GRID) * $GRID AS lng, + COUNT(*) AS demand + FROM ride + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 2 HOUR) + AND start_location IS NOT NULL AND start_location != '' + GROUP BY lat, lng + HAVING demand >= 1 + "); + + $supplyRaw = safeQuery($con, " + SELECT + ROUND(latitude / $GRID) * $GRID AS lat, + ROUND(longitude / $GRID) * $GRID AS lng, + COUNT(*) AS supply + FROM car_locations + WHERE status = 'on' + AND latitude != 0 AND longitude != 0 + GROUP BY lat, lng + "); + + $supplyMap = []; + foreach ($supplyRaw as $s) { + $key = $s['lat'] . '_' . $s['lng']; + $supplyMap[$key] = (int)$s['supply']; + } + + $gapCells = []; + foreach ($demandRaw as $d) { + $key = $d['lat'] . '_' . $d['lng']; + $supply = $supplyMap[$key] ?? 0; + $demand = (int)$d['demand']; + $gap = $demand - $supply; + $gapCells[] = [ + 'lat' => (float)$d['lat'], + 'lng' => (float)$d['lng'], + 'demand' => $demand, + 'supply' => $supply, + 'gap' => $gap, + 'ratio' => $supply > 0 ? round($demand / $supply, 2) : ($demand > 0 ? 99.0 : 0), + ]; + } + + usort($gapCells, fn($a, $b) => $b['gap'] <=> $a['gap']); + $gapCells = array_slice($gapCells, 0, 200); + + saveSnapshot("supply_demand_gap.json", [ + 'cells' => $gapCells, + 'total_demand_cells' => count($demandRaw), + 'total_supply_cells' => count($supplyRaw), + ], $todayDir); + + // ─── كاش الخريطة الحرارية من Redis ─── + echo "[Snapshot] Heatmap cache from Redis...\n"; + $heatmapRedis = $redis ? $redis->get('siro:cache:heatmap:data') : null; + if ($heatmapRedis) { + $heatmapData = json_decode($heatmapRedis, true); + saveSnapshot("heatmap.json", $heatmapData ?: [], $todayDir); + } + + // ─── كاش التسعير من Redis ─── + echo "[Snapshot] Pricing cache from Redis...\n"; + $pricingRedis = $redis ? $redis->get('siro:cache:pricing:grids') : null; + if ($pricingRedis) { + $pricingData = json_decode($pricingRedis, true); + saveSnapshot("pricing_grids.json", $pricingData ?: [], $todayDir); + } + + // ─── بيانات الكرون Predictive Demand من Redis ─── + $predictiveRedis = $redis ? $redis->get('siro:predictive_demand:latest') : null; + if ($predictiveRedis) { + saveSnapshot("predictive_demand.json", json_decode($predictiveRedis, true) ?: [], $todayDir); + } +} + +// ───────────────────────────────────────────────────────────── +// 2. لقطة يومية (تحليلات ثقيلة) +// ───────────────────────────────────────────────────────────── +if (in_array($mode, ['daily', 'weekly'])) { + echo "[Snapshot] Daily analytics...\n"; + + // ─── الإيرادات اليومية (30 يوم) ─── + $revenueDaily = safeQuery($conRide, " + SELECT DATE(created_at) as date, + SUM(price) as total_revenue, + SUM(price - price_for_driver) as company_profit, + COUNT(*) as total_rides, + AVG(price) as avg_fare + FROM ride + WHERE status = 'Finished' AND created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + GROUP BY DATE(created_at) + ORDER BY date ASC + "); + saveSnapshot("revenue_30d.json", ['daily' => $revenueDaily], $todayDir); + + // ─── النمو (ركاب + كباتن) ─── + $passengerGrowth = safeQuery($con, " + SELECT DATE(created_at) as date, COUNT(*) as count + FROM passengers + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + GROUP BY DATE(created_at) ORDER BY date ASC + "); + $driverGrowth = safeQuery($con, " + SELECT DATE(created_at) as date, COUNT(*) as count + FROM driver + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + GROUP BY DATE(created_at) ORDER BY date ASC + "); + $totalPassengers = (int)safeScalar($con, "SELECT COUNT(*) FROM passengers"); + $totalDrivers = (int)safeScalar($con, "SELECT COUNT(*) FROM driver"); + + saveSnapshot("growth_30d.json", [ + 'passengers' => $passengerGrowth, + 'drivers' => $driverGrowth, + 'totals' => ['passengers' => $totalPassengers, 'drivers' => $totalDrivers], + ], $todayDir); + + // ─── توزيع الرحلات حسب الساعة (لاكتشاف الأنماط) ─── + $hourlyPattern = safeQuery($conRide, " + SELECT HOUR(created_at) as hour, COUNT(*) as rides, + AVG(price) as avg_price + FROM ride + WHERE status = 'Finished' AND created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) + GROUP BY HOUR(created_at) ORDER BY hour + "); + saveSnapshot("hourly_pattern.json", ['hours' => $hourlyPattern], $todayDir); + + // ─── توزيع حالات الرحلات (Funnel) ─── + $rideFunnel = safeQuery($conRide, " + SELECT status, COUNT(*) as count + FROM ride + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) + GROUP BY status + "); + saveSnapshot("ride_funnel.json", ['statuses' => $rideFunnel], $todayDir); + + // ─── أسعار المنافسين (تاريخي ٢٤ ساعة) ─── + $competitorHourly = safeQuery($con, " + SELECT DATE_FORMAT(created_at,'%Y-%m-%d %H:00:00') AS hour_bucket, + competitor_name, + AVG(price_per_km) AS avg_price, + COUNT(*) AS samples + FROM scraped_competitor_prices + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) + GROUP BY hour_bucket, competitor_name + ORDER BY hour_bucket ASC + "); + saveSnapshot("competitor_prices_24h.json", ['hourly' => $competitorHourly], $todayDir); + + // ─── صحة السوق (market health) ─── + $countries = ['JO', 'SY', 'EG', 'IQ']; + $marketHealth = []; + foreach ($countries as $cc) { + $rows = safeQuery($con, " + SELECT report_date, average_pci, market_share_percent, total_anomalies + FROM market_health_reports + WHERE country_code = :cc + ORDER BY report_date DESC LIMIT 12 + ", [':cc' => $cc]); + if (!empty($rows)) { + $marketHealth[$cc] = array_reverse($rows); + } + } + saveSnapshot("market_health.json", ['countries' => $marketHealth], $todayDir); + + // ─── شكاوى مفتوحة ─── + $complaints = safeQuery($con, " + SELECT complaint_type, COUNT(*) as count + FROM complaint + WHERE statusComplaint = 'Open' + GROUP BY complaint_type + ORDER BY count DESC + "); + saveSnapshot("complaints_open.json", ['by_type' => $complaints], $todayDir); +} + +// ───────────────────────────────────────────────────────────── +// 3. لقطة أسبوعية (مقارنات وتجميعات) +// ───────────────────────────────────────────────────────────── +if ($mode === 'weekly') { + echo "[Snapshot] Weekly deep analytics...\n"; + + // ─── مقارنة أسبوع بأسبوع ─── + $weeklyComparison = safeQuery($conRide, " + SELECT + YEARWEEK(created_at, 1) as yw, + MIN(DATE(created_at)) as week_start, + COUNT(*) as rides, + SUM(price) as revenue, + SUM(price - price_for_driver) as profit, + COUNT(DISTINCT driver_id) as active_drivers, + COUNT(DISTINCT passenger_id) as active_passengers + FROM ride + WHERE status = 'Finished' AND created_at >= DATE_SUB(CURDATE(), INTERVAL 12 WEEK) + GROUP BY yw ORDER BY yw ASC + "); + saveSnapshot("weekly_comparison.json", ['weeks' => $weeklyComparison], $todayDir); + + // ─── أعلى مناطق (أكثر 20 خلية نشاطاً) ─── + $topZones = safeQuery($conRide, " + SELECT + ROUND(SUBSTRING_INDEX(start_location,',',1) / 0.01) * 0.01 AS lat, + ROUND(SUBSTRING_INDEX(start_location,',',-1) / 0.01) * 0.01 AS lng, + COUNT(*) AS rides, + AVG(price) AS avg_price + FROM ride + WHERE status = 'Finished' + AND created_at >= DATE_SUB(CURDATE(), INTERVAL 4 WEEK) + AND start_location IS NOT NULL AND start_location != '' + GROUP BY lat, lng + ORDER BY rides DESC + LIMIT 20 + "); + saveSnapshot("top_zones.json", ['zones' => $topZones], $todayDir); + + // ─── احتفاظ الركاب (Retention) ─── + $retention = safeQuery($conRide, " + SELECT + weeks_since_signup, + COUNT(DISTINCT passenger_id) as active_passengers + FROM ( + SELECT r.passenger_id, + FLOOR(DATEDIFF(r.created_at, p.created_at) / 7) as weeks_since_signup + FROM ride r + JOIN passengers p ON r.passenger_id = p.id + WHERE r.status = 'Finished' + AND r.created_at >= DATE_SUB(CURDATE(), INTERVAL 12 WEEK) + ) sub + GROUP BY weeks_since_signup + ORDER BY weeks_since_signup + "); + saveSnapshot("retention_cohort.json", ['cohorts' => $retention], $todayDir); +} + +// ───────────────────────────────────────────────────────────── +// 4. إنشاء manifest يسهّل على الداشبورد معرفة الملفات المتاحة +// ───────────────────────────────────────────────────────────── +$files = glob("$todayDir/*.json"); +$manifest = []; +foreach ($files as $f) { + $name = basename($f, '.json'); + if ($name === 'manifest') continue; + $manifest[$name] = [ + 'file' => basename($f), + 'size' => filesize($f), + 'updated' => date('Y-m-d H:i:s', filemtime($f)), + ]; +} +saveSnapshot("manifest.json", ['snapshots' => $manifest, 'date' => $today], $todayDir); + +// ─── فهرس الأيام المتاحة (لتسهيل التصفح التاريخي) ─── +$days = array_map('basename', glob("$cacheBase/20*", GLOB_ONLYDIR)); +rsort($days); +file_put_contents("$cacheBase/index.json", json_encode([ + 'available_dates' => $days, + 'latest' => $days[0] ?? $today, + 'updated' => $now, +], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + +echo "[DashboardSnapshot] Done! Files in $todayDir\n"; diff --git a/siro_admin/lib/constant/links.dart b/siro_admin/lib/constant/links.dart index b8f4f2ec..3f0c8839 100644 --- a/siro_admin/lib/constant/links.dart +++ b/siro_admin/lib/constant/links.dart @@ -411,6 +411,8 @@ class AppLink { "$server/Admin/v2/quality/blacklist_manager.php"; static String driverScorecard = "$server/Admin/v2/quality/driver_scorecard.php"; + static String analyticsDashboard = + "$server/Admin/v2/analytics/dashboard/index.php"; static String getEmployee = "$server/Admin/employee/get.php"; static String getBestDriver = "$server/Admin/driver/getBestDriver.php"; static String getBestDriverGiza = diff --git a/siro_admin/lib/views/admin/admin_home_page.dart b/siro_admin/lib/views/admin/admin_home_page.dart index ec47521d..7df00017 100644 --- a/siro_admin/lib/views/admin/admin_home_page.dart +++ b/siro_admin/lib/views/admin/admin_home_page.dart @@ -35,6 +35,8 @@ import 'dashboard_v2_widget.dart'; import 'static/advanced_analytics_page.dart'; import 'financial/financial_v2_page.dart'; import 'security/audit_logs_page.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../constant/links.dart'; class AdminHomePage extends StatefulWidget { const AdminHomePage({super.key}); @@ -778,6 +780,12 @@ class _AdminHomePageState extends State }), ActionItem('التحليلات المتقدمة', Icons.analytics_rounded, _info, () => Get.to(() => const AdvancedAnalyticsPage())), + ActionItem('لوحة البيانات التفاعلية', Icons.dashboard_customize_rounded, + const Color(0xFF00CEC9), () async { + final token = box.read(BoxName.jwt) ?? ''; + final url = '${AppLink.analyticsDashboard}?token=$token'; + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + }), ], ), ActionCategory(