Update: 2026-07-10 23:59:23

This commit is contained in:
Hamza-Ayed
2026-07-10 23:59:23 +03:00
parent 6294d6e725
commit 04468dad08
5 changed files with 1594 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
<?php
/**
* dashboard_data.php
* API موحّد للداشبورد التحليلي — يقرأ من ملفات JSON المؤرشفة + بيانات حيّة من Redis.
*
* Parameters:
* date (optional) — YYYY-MM-DD, default: today
* section (optional) — realtime|gap|heatmap|pricing|revenue|growth|market|complaints|funnel|hourly|weekly|zones|retention|all
* default: all
*/
require_once __DIR__ . '/../../../connect.php';
if ($role !== 'admin' && $role !== 'super_admin') {
http_response_code(403);
echo json_encode(['status' => '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']);
}
+350
View File
@@ -0,0 +1,350 @@
<?php
/**
* cron_dashboard_snapshot.php
* يجمع بيانات التحليلات من Redis وقاعدة البيانات ويخزنها كملفات JSON مؤرشفة.
*
* الجدولة المقترحة:
* - لقطة سريعة (ساعي): 0 * * * * php cron_dashboard_snapshot.php hourly
* - لقطة يومية: 0 3 * * * php cron_dashboard_snapshot.php daily
* - لقطة أسبوعية: 0 4 * * 1 php cron_dashboard_snapshot.php weekly
*/
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../functions.php';
set_time_limit(180);
ini_set('memory_limit', '256M');
$mode = $argv[1] ?? 'hourly';
$today = date('Y-m-d');
$now = date('Y-m-d H:i:s');
$cacheBase = __DIR__ . '/../cache/analytics';
$todayDir = "$cacheBase/$today";
if (!is_dir($todayDir)) {
mkdir($todayDir, 0755, true);
}
try {
$con = Database::get('main');
$conRide = Database::get('ride');
$redis = getRedisConnection();
} catch (Exception $e) {
die("[DashboardSnapshot] Connection failed: " . $e->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";
+2
View File
@@ -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 =
@@ -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<AdminHomePage>
}),
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(