351 lines
15 KiB
PHP
351 lines
15 KiB
PHP
<?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";
|