قرار المالك 2026-07-27: باك إند سيرو PHP هو المعتمد، وتطبيقاته المجرّبة ميدانياً تحل محل إعادة البناء المؤرشفة. سيرو نفسه لم يُمسّ. الخريطة: backend · payment_server · loction_server · ride_server · passenger_server · docker · dashboard · stress_test → الجذر siro_rider → apps/rider siro_driver → apps/driver siro_admin → dashboards/admin siro_service → dashboards/service android_bot → apps/android_bot socialBot → apps/socialBot نُسخ المتعقَّب في git سيرو فقط عبر `git archive` (3,198 ملفاً / ~169 م.ب) لا `cp -r` — فاستُثنيت مخلفات البناء تلقائياً. بلا أي تعديل محتوى عمداً: كل ما يلي يصير فرقاً مقروءاً مقابل المصدر. لم يُستورد وسببه: siromove.com (الموقع التسويقي يبقى marketing/ في تريبز، سيرو فيه 8 ملفات) · docs و planning (تريبز له docs/ الخاص) · deploy.sh (ليس نشراً على سيرفر بل `git add . && git push origin --all` — فخّ في مستودع آخر) · transit_dashboard (بانتظار قرار مصير backend-transit و dashboards/transit-web). ⚠️ لا يبني بعد — ثلاثة نواقص متوقعة ومقصودة: 1. `.env` و `lib/env/env.g.dart` غير متعقَّبين في سيرو (أسرار لكل مستأجر): كل تطبيق فلاتر يحتاج .env خاصاً ثم توليد env.g.dart بـ build_runner. 2. إعدادات Firebase (9 ملفات google-services.json و GoogleService-Info.plist) يستبعدها .gitignore تريبز — ولكل مستأجر مشروع Firebase خاص أصلاً. 3. apps/driver في سيرو يشير إلى `../../Intaleq/packages/get` خارج المستودع → يجب ضمّ الحزم داخله أسوة بـ apps/rider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
258 lines
10 KiB
PHP
258 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* cron_predictive_demand.php
|
|
* ─────────────────────────────────────────────────────────────
|
|
* التنبؤ الذكي بالطلب: يحلل بيانات الرحلات التاريخية ويرسل
|
|
* إشعارات للسائقين المتاحين يخبرهم بالمناطق المتوقع ارتفاع
|
|
* الطلب فيها خلال الـ 30 دقيقة القادمة.
|
|
*
|
|
* المنطق:
|
|
* 1. يحسب متوسط عدد الطلبات لكل خلية (grid 0.01°×0.01°) في
|
|
* نفس اليوم + نفس الساعة من آخر 4 أسابيع
|
|
* 2. يرتب الخلايا تنازلياً ويختار أفضل 10
|
|
* 3. يجد السائقين المتاحين (is_online=1) داخل 5km من كل خلية
|
|
* 4. يرسل FCM مخصص لكل سائق (مرة كل ساعتين anti-spam)
|
|
* 5. يحفظ النتائج في Redis للـ API
|
|
*
|
|
* جدولة مقترحة (crontab):
|
|
* 0 * * * * php /var/www/backend/bot/cron_predictive_demand.php
|
|
* (كل ساعة)
|
|
*/
|
|
|
|
require_once __DIR__ . '/../core/bootstrap.php';
|
|
require_once __DIR__ . '/../functions.php';
|
|
require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
|
|
|
|
set_time_limit(120);
|
|
ini_set('memory_limit', '256M');
|
|
|
|
try {
|
|
$con = Database::get('main');
|
|
$conRide = Database::get('ride');
|
|
$redis = getRedisConnection();
|
|
} catch (Exception $e) {
|
|
die("[PredictiveDemand] Connection failed: " . $e->getMessage() . "\n");
|
|
}
|
|
|
|
echo "[PredictiveDemand] Starting...\n";
|
|
|
|
$GRID_SIZE = 0.01; // حجم الخلية (~1.1km)
|
|
$RADIUS_DEGREES = 0.045; // ~5km
|
|
$ANTI_SPAM_TTL = 7200; // 2 ساعة
|
|
$TOP_ZONES = 10; // أفضل 10 مناطق
|
|
$WEEKS_HISTORY = 4; // تحليل آخر 4 أسابيع
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// 1. تحليل الطلبات التاريخية (نفس الساعة + نفس اليوم من الأسبوع)
|
|
// ─────────────────────────────────────────────────────────────
|
|
$currentHour = (int)date('H');
|
|
$currentDow = (int)date('N'); // 1=Mon ... 7=Sun
|
|
$targetHourNext = ($currentHour + 1) % 24; // الساعة القادمة (المتوقع)
|
|
|
|
$sqlHistory = "
|
|
SELECT
|
|
ROUND(SUBSTRING_INDEX(start_location, ',', 1) / {$GRID_SIZE}) * {$GRID_SIZE} AS cell_lat,
|
|
ROUND(SUBSTRING_INDEX(start_location, ',', -1) / {$GRID_SIZE}) * {$GRID_SIZE} AS cell_lng,
|
|
'JO' AS country_code,
|
|
COUNT(*) AS demand_score
|
|
FROM ride
|
|
WHERE
|
|
status IN ('completed', 'cancelled_by_driver', 'timeout')
|
|
AND HOUR(created_at) = :next_hour
|
|
AND DAYOFWEEK(created_at) = :dow
|
|
AND created_at >= DATE_SUB(NOW(), INTERVAL :weeks WEEK)
|
|
AND start_location != ''
|
|
AND start_location IS NOT NULL
|
|
GROUP BY cell_lat, cell_lng, country_code
|
|
HAVING demand_score >= 2
|
|
ORDER BY demand_score DESC
|
|
LIMIT :top
|
|
";
|
|
|
|
try {
|
|
$stmt = $conRide->prepare($sqlHistory);
|
|
$stmt->bindValue(':next_hour', $targetHourNext, PDO::PARAM_INT);
|
|
$stmt->bindValue(':dow', $currentDow, PDO::PARAM_INT);
|
|
$stmt->bindValue(':weeks', $WEEKS_HISTORY, PDO::PARAM_INT);
|
|
$stmt->bindValue(':top', $TOP_ZONES * 3, PDO::PARAM_INT); // نأخذ أكثر لنصفيها لاحقاً
|
|
$stmt->execute();
|
|
$hotZones = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
die("[PredictiveDemand] Query error: " . $e->getMessage() . "\n");
|
|
}
|
|
|
|
if (empty($hotZones)) {
|
|
echo "[PredictiveDemand] Not enough historical data for this hour. Exiting.\n";
|
|
exit;
|
|
}
|
|
|
|
echo "[PredictiveDemand] Found " . count($hotZones) . " hot zones.\n";
|
|
|
|
// خذ أفضل N منطقة
|
|
$hotZones = array_slice($hotZones, 0, $TOP_ZONES);
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// 2. تسمية المناطق باستخدام geofence_zones الموجودة أو اسم
|
|
// عام بناءً على الإحداثيات
|
|
// ─────────────────────────────────────────────────────────────
|
|
function getZoneName($con, $lat, $lng, $radius)
|
|
{
|
|
try {
|
|
$stmt = $con->prepare("
|
|
SELECT zone_name
|
|
FROM geofence_zones
|
|
WHERE is_active = 1
|
|
AND ABS(latitude - :lat) < :r
|
|
AND ABS(longitude - :lng) < :r
|
|
ORDER BY ABS(latitude - :lat) + ABS(longitude - :lng) ASC
|
|
LIMIT 1
|
|
");
|
|
$stmt->execute([':lat' => $lat, ':lng' => $lng, ':r' => $radius]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
return $row ? $row['zone_name'] : null;
|
|
} catch (Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// 3. حفظ النتائج في Redis للـ API
|
|
// ─────────────────────────────────────────────────────────────
|
|
$redisData = [];
|
|
foreach ($hotZones as &$zone) {
|
|
$zoneName = getZoneName($con, $zone['cell_lat'], $zone['cell_lng'], $RADIUS_DEGREES);
|
|
$zone['zone_name'] = $zoneName ?? "منطقة ({$zone['cell_lat']}, {$zone['cell_lng']})";
|
|
$redisData[] = [
|
|
'lat' => (float)$zone['cell_lat'],
|
|
'lng' => (float)$zone['cell_lng'],
|
|
'zone_name' => $zone['zone_name'],
|
|
'demand_score' => (int)$zone['demand_score'],
|
|
'predicted_hour' => $targetHourNext,
|
|
'country_code' => $zone['country_code'],
|
|
];
|
|
}
|
|
unset($zone);
|
|
|
|
if ($redis) {
|
|
$redis->setex(
|
|
'siro:cache:predictive_demand',
|
|
3600, // TTL ساعة واحدة
|
|
json_encode([
|
|
'last_updated' => date('Y-m-d H:i:s'),
|
|
'predicted_hour' => $targetHourNext,
|
|
'zones' => $redisData,
|
|
], JSON_UNESCAPED_UNICODE)
|
|
);
|
|
echo "[PredictiveDemand] Zones cached in Redis.\n";
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// 4. إيجاد السائقين المتاحين قرب كل منطقة وإرسال إشعار
|
|
// ─────────────────────────────────────────────────────────────
|
|
$encryptionHelper = new EncryptionHelper();
|
|
$totalSent = 0;
|
|
$totalSkipped = 0;
|
|
$notifiedDrivers = []; // لتجنب إرسال نفس السائق أكثر من إشعار
|
|
|
|
foreach ($hotZones as $zone) {
|
|
$cellLat = (float)$zone['cell_lat'];
|
|
$cellLng = (float)$zone['cell_lng'];
|
|
$zoneName = $zone['zone_name'];
|
|
$score = (int)$zone['demand_score'];
|
|
$minutes = 30; // التوقع دائماً 30 دقيقة للأمام
|
|
|
|
// استخرج رسالة مناسبة بناءً على حجم الطلب
|
|
if ($score >= 10) {
|
|
$intensity = 'مرتفع جداً 🔥🔥';
|
|
} elseif ($score >= 5) {
|
|
$intensity = 'مرتفع 🔥';
|
|
} else {
|
|
$intensity = 'متوسط 📈';
|
|
}
|
|
|
|
$title = "🔮 توقع ذكي من سيرو";
|
|
$body = "نتوقع طلب {$intensity} من منطقة «{$zoneName}» خلال {$minutes} دقيقة. اتجه الآن! 🚗";
|
|
|
|
// ── جلب السائقين المتاحين في نطاق 5km ──────────────────
|
|
$sqlDrivers = "
|
|
SELECT
|
|
d.id AS driver_id,
|
|
t.token,
|
|
dl.lat,
|
|
dl.lng
|
|
FROM drivers d
|
|
JOIN driver_live_location dl ON dl.driver_id = d.id
|
|
JOIN tokens t ON t.driverID = d.id
|
|
WHERE d.status_driver = 'free'
|
|
AND d.verified = 1
|
|
AND dl.updated_at >= DATE_SUB(NOW(), INTERVAL 15 MINUTE)
|
|
AND ABS(dl.lat - :lat) < :radius
|
|
AND ABS(dl.lng - :lng) < :radius
|
|
LIMIT 50
|
|
";
|
|
|
|
try {
|
|
$stmtD = $con->prepare($sqlDrivers);
|
|
$stmtD->execute([
|
|
':lat' => $cellLat,
|
|
':lng' => $cellLng,
|
|
':radius' => $RADIUS_DEGREES,
|
|
]);
|
|
$nearbyDrivers = $stmtD->fetchAll(PDO::FETCH_ASSOC);
|
|
} catch (PDOException $e) {
|
|
// جدول driver_live_location قد يختلف اسمه
|
|
error_log("[PredictiveDemand] Driver query error: " . $e->getMessage());
|
|
continue;
|
|
}
|
|
|
|
echo "[PredictiveDemand] Zone [{$zoneName}] score={$score} → " . count($nearbyDrivers) . " nearby drivers.\n";
|
|
|
|
foreach ($nearbyDrivers as $driver) {
|
|
$driverId = $driver['driver_id'];
|
|
|
|
// تخطّى إذا أرسلنا لهذا السائق سابقاً في نفس الـ cron run
|
|
if (isset($notifiedDrivers[$driverId])) {
|
|
$totalSkipped++;
|
|
continue;
|
|
}
|
|
|
|
// Anti-spam: مرة كل ساعتين لكل سائق
|
|
$redisKey = "predictive:sent:{$driverId}";
|
|
if ($redis && $redis->exists($redisKey)) {
|
|
$totalSkipped++;
|
|
continue;
|
|
}
|
|
|
|
$decryptedToken = $encryptionHelper->decryptData($driver['token'] ?? '');
|
|
if (!$decryptedToken) continue;
|
|
|
|
sendFCM_Internal(
|
|
$decryptedToken,
|
|
$title,
|
|
$body,
|
|
[
|
|
'type' => 'predictive_demand',
|
|
'zone_name' => $zoneName,
|
|
'zone_lat' => $cellLat,
|
|
'zone_lng' => $cellLng,
|
|
'demand_score' => $score,
|
|
'predicted_hour' => $targetHourNext,
|
|
],
|
|
'System',
|
|
false,
|
|
'ding'
|
|
);
|
|
|
|
// حفظ في Redis anti-spam
|
|
if ($redis) {
|
|
$redis->setex($redisKey, $ANTI_SPAM_TTL, '1');
|
|
}
|
|
|
|
$notifiedDrivers[$driverId] = true;
|
|
$totalSent++;
|
|
}
|
|
}
|
|
|
|
echo "[PredictiveDemand] Done ✅ — Sent: {$totalSent}, Skipped: {$totalSkipped}\n";
|
|
?>
|