Update: 2026-07-04 04:05:08

This commit is contained in:
Hamza-Ayed
2026-07-04 04:05:08 +03:00
parent e4ba962ab6
commit c7bd644b88
14 changed files with 807 additions and 26 deletions
@@ -0,0 +1,150 @@
<?php
/**
* getPredictiveDemandZones.php
* ─────────────────────────────────────────────────────────────
* API endpoint: يُعيد للسائق قائمة بأفضل المناطق المتوقع
* ارتفاع الطلب فيها خلال الساعة القادمة.
*
* يقرأ من Redis أولاً (cache يُجدَّد كل ساعة بالـ cron)،
* ثم يُصفّي أقرب المناطق لموقع السائق الحالي.
*
* Request (GET/POST):
* lat — خط العرض الحالي للسائق
* lng — خط الطول الحالي للسائق
* radius — نطاق البحث بالكيلومترات (اختياري، افتراضي: 10)
*
* Response:
* { success: true, zones: [...], predicted_hour: X, last_updated: "..." }
*/
require_once __DIR__ . '/../../core/bootstrap.php';
require_once __DIR__ . '/../../functions.php';
header('Content-Type: application/json; charset=utf-8');
// ── Auth ──────────────────────────────────────────────────────
// يستخدم نفس آلية JWT الموجودة في النظام
$driverId = getAuthDriverId(); // دالة موجودة في bootstrap/functions
if (!$driverId) {
http_response_code(401);
echo json_encode(['success' => false, 'message' => 'Unauthorized']);
exit;
}
// ── Parameters ────────────────────────────────────────────────
$driverLat = (float)filterRequest('lat');
$driverLng = (float)filterRequest('lng');
$radiusKm = (float)(filterRequest('radius') ?? 10);
// تحويل km إلى درجات تقريباً (1° ≈ 111km)
$radiusDeg = $radiusKm / 111.0;
// ── Redis Cache ───────────────────────────────────────────────
try {
$redis = getRedisConnection();
} catch (Exception $e) {
$redis = null;
}
$cacheRaw = $redis ? $redis->get('siro:cache:predictive_demand') : null;
if ($cacheRaw) {
$cacheData = json_decode($cacheRaw, true);
$allZones = $cacheData['zones'] ?? [];
// فلتر: فقط المناطق ضمن نطاق السائق
$nearbyZones = array_filter($allZones, function ($z) use ($driverLat, $driverLng, $radiusDeg) {
if ($driverLat == 0 || $driverLng == 0) return true; // لو ما أرسل موقعه، أرجع الكل
return abs($z['lat'] - $driverLat) <= $radiusDeg
&& abs($z['lng'] - $driverLng) <= $radiusDeg;
});
// ترتيب: الأقرب للسائق أولاً
if ($driverLat != 0) {
usort($nearbyZones, function ($a, $b) use ($driverLat, $driverLng) {
$da = abs($a['lat'] - $driverLat) + abs($a['lng'] - $driverLng);
$db = abs($b['lat'] - $driverLat) + abs($b['lng'] - $driverLng);
return $da <=> $db;
});
}
echo json_encode([
'success' => true,
'source' => 'cache',
'predicted_hour' => $cacheData['predicted_hour'] ?? null,
'last_updated' => $cacheData['last_updated'] ?? null,
'zones' => array_values($nearbyZones),
], JSON_UNESCAPED_UNICODE);
exit;
}
// ── Fallback: حساب مباشر من DB إذا لم يتوفر cache ────────────
try {
$con = Database::get('main');
} catch (Exception $e) {
echo json_encode(['success' => false, 'message' => 'DB error']);
exit;
}
$nextHour = ((int)date('H') + 1) % 24;
$dow = (int)date('N');
$gridSize = 0.01;
$sql = "
SELECT
ROUND(pickup_lat / :g) * :g AS lat,
ROUND(pickup_lng / :g) * :g AS lng,
COUNT(*) AS demand_score,
country_code
FROM rides
WHERE
status IN ('completed', 'cancelled_by_driver', 'timeout')
AND HOUR(created_at) = :hour
AND DAYOFWEEK(created_at) = :dow
AND created_at >= DATE_SUB(NOW(), INTERVAL 4 WEEK)
AND pickup_lat BETWEEN (:dlat - :r) AND (:dlat + :r)
AND pickup_lng BETWEEN (:dlng - :r) AND (:dlng + :r)
GROUP BY lat, lng, country_code
HAVING demand_score >= 2
ORDER BY demand_score DESC
LIMIT 10
";
$stmt = $con->prepare($sql);
$stmt->execute([
':g' => $gridSize,
':hour' => $nextHour,
':dow' => $dow,
':dlat' => $driverLat ?: 31.95, // fallback عمّان
':dlng' => $driverLng ?: 35.93,
':r' => $radiusDeg,
]);
$zones = $stmt->fetchAll(PDO::FETCH_ASSOC);
// تسمية المناطق
foreach ($zones as &$z) {
$stmtN = $con->prepare("
SELECT zone_name FROM geofence_zones
WHERE is_active = 1
AND ABS(latitude - :lat) < 0.05
AND ABS(longitude - :lng) < 0.05
ORDER BY ABS(latitude - :lat) + ABS(longitude - :lng) ASC
LIMIT 1
");
$stmtN->execute([':lat' => $z['lat'], ':lng' => $z['lng']]);
$nameRow = $stmtN->fetch(PDO::FETCH_ASSOC);
$z['zone_name'] = $nameRow['zone_name'] ?? 'منطقة قريبة';
$z['lat'] = (float)$z['lat'];
$z['lng'] = (float)$z['lng'];
$z['demand_score'] = (int)$z['demand_score'];
}
unset($z);
echo json_encode([
'success' => true,
'source' => 'realtime',
'predicted_hour' => $nextHour,
'last_updated' => date('Y-m-d H:i:s'),
'zones' => $zones,
], JSON_UNESCAPED_UNICODE);
?>
+42 -14
View File
@@ -20,7 +20,8 @@ require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
set_time_limit(0);
try {
$con = Database::get('main');
$con = Database::get('main');
$redis = getRedisConnection();
} catch (Exception $e) {
die("Database connection failed: " . $e->getMessage() . "\n");
}
@@ -101,22 +102,49 @@ foreach ($passengersByCountry as $countryCode => $passengers) {
// 4. إرسال الـ Push Notification لكل راكب
foreach ($passengers as $p) {
$passengerId = $p['passenger_id'];
$encryptedToken = $p['token'];
$decryptedToken = $encryptionHelper->decryptData($encryptedToken);
if ($decryptedToken) {
$dataPayload = [
'type' => 'marketing_promo',
'promo_code' => $promoCode
];
// استدعاء دالة الإرسال (يفترض وجودها في نظام FCM الخاص بك، مثل sendFCM)
// sendFCM($decryptedToken, $title, $body, $dataPayload);
// سنطبع للـ Log فقط في هذا السكربت لتجنب إرسال رسائل حقيقية دون تفعيل FCM الفعلي
error_log("Sending Push to passenger {$p['passenger_id']}: $title");
$totalPushes++;
if (!$decryptedToken) continue;
// ── Anti-spam: لا يُرسَل لنفس الراكب أكثر من مرة/24 ساعة ──
$redisKey = "marketing_push:sent:{$passengerId}";
if ($redis && $redis->exists($redisKey)) {
continue;
}
$dataPayload = [
'type' => 'marketing_promo',
'promo_code' => $promoCode,
'screen' => 'home',
];
// ── إرسال FCM الفعلي ──────────────────────────────────────
sendFCM_Internal(
$decryptedToken,
$title,
$body,
$dataPayload,
'Marketing',
false,
'ding'
);
// ── تسجيل anti-spam في Redis (TTL 24 ساعة) ───────────────
if ($redis) {
$redis->setex($redisKey, 86400, '1');
}
// ── حفظ في جدول notifications الداخلي ────────────────────
try {
$ins = $con->prepare(
"INSERT INTO notifications (title, body, passenger_id) VALUES (:t, :b, :pid)"
);
$ins->execute([':t' => $title, ':b' => $body, ':pid' => $passengerId]);
} catch (Exception $ignored) {}
$totalPushes++;
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
/**
* cron_passenger_reengagement.php
* ─────────────────────────────────────────────────────────────
* Re-engagement cron: يرسل إشعار تسويقي مخصص (عبر Gemini AI)
* للركاب الذين لم يفتحوا التطبيق منذ 3 أيام أو أكثر.
*
* المصدر: جدول passenger_opening_locations (آخر نشاط مسجّل)
* Anti-spam: Redis — لا يُرسَل لنفس الراكب أكثر من مرة كل 72 ساعة
*
* جدولة مقترحة (crontab):
* 0 10 * * * php /var/www/backend/bot/cron_passenger_reengagement.php
* (يعمل يومياً الساعة 10 صباحاً)
*/
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../core/Services/SiroGeminiService.php';
require_once __DIR__ . '/../core/Security/EncryptionHelper.php';
require_once __DIR__ . '/../functions.php';
set_time_limit(0);
ini_set('memory_limit', '256M');
try {
$con = Database::get('main');
$redis = getRedisConnection();
} catch (Exception $e) {
die("[ReEngagement] Connection failed: " . $e->getMessage() . "\n");
}
echo "[ReEngagement] Starting Passenger Re-engagement Engine...\n";
$REDIS_TTL = 72 * 3600; // anti-spam: 72 ساعة
$INACTIVE_DAYS = 3; // عدد أيام الخمول
$BATCH_LIMIT = 500; // أقصى عدد ركاب لكل تشغيل
// ─────────────────────────────────────────────────────────────
// 1. جلب الركاب الخاملين (آخر نشاطهم منذ 3 أيام أو أكثر)
// نعتمد على passenger_opening_locations كمصدر لآخر نشاط
// ─────────────────────────────────────────────────────────────
$sql = "
SELECT
p.id AS passenger_id,
p.country_code,
t.token,
MAX(pol.created_at) AS last_active
FROM passengers p
JOIN tokens t ON t.passengerID = p.id
LEFT JOIN passenger_opening_locations pol ON pol.passenger_id = p.id
WHERE p.status = 'notDeleted'
GROUP BY p.id, p.country_code, t.token
HAVING
last_active IS NULL
OR last_active < DATE_SUB(NOW(), INTERVAL :days DAY)
ORDER BY last_active ASC
LIMIT :limit
";
try {
$stmt = $con->prepare($sql);
$stmt->bindValue(':days', $INACTIVE_DAYS, PDO::PARAM_INT);
$stmt->bindValue(':limit', $BATCH_LIMIT, PDO::PARAM_INT);
$stmt->execute();
$inactivePassengers = $stmt->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
// Fallback: جدول passenger_opening_locations قد لا يملك passenger_id عمود — نستخدم start_location
$sqlFallback = "
SELECT
p.id AS passenger_id,
p.country_code,
t.token,
MAX(pol.date) AS last_active
FROM passengers p
JOIN tokens t ON t.passengerID = p.id
LEFT JOIN passenger_opening_locations pol ON pol.start_location = p.id
WHERE p.status = 'notDeleted'
GROUP BY p.id, p.country_code, t.token
HAVING
last_active IS NULL
OR last_active < DATE_SUB(NOW(), INTERVAL :days DAY)
ORDER BY last_active ASC
LIMIT :limit
";
$stmt = $con->prepare($sqlFallback);
$stmt->bindValue(':days', $INACTIVE_DAYS, PDO::PARAM_INT);
$stmt->bindValue(':limit', $BATCH_LIMIT, PDO::PARAM_INT);
$stmt->execute();
$inactivePassengers = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
if (empty($inactivePassengers)) {
echo "[ReEngagement] No inactive passengers found. All good!\n";
exit;
}
echo "[ReEngagement] Found " . count($inactivePassengers) . " inactive passengers (≥{$INACTIVE_DAYS} days).\n";
// ─────────────────────────────────────────────────────────────
// 2. تجميع حسب الدولة لتقليل استدعاءات Gemini
// ─────────────────────────────────────────────────────────────
$byCountry = [];
foreach ($inactivePassengers as $p) {
$cc = strtoupper($p['country_code'] ?? 'SY');
$byCountry[$cc][] = $p;
}
$geminiService = new SiroGeminiService();
$encryptionHelper = new EncryptionHelper();
$sentCount = 0;
$skippedCount = 0;
foreach ($byCountry as $countryCode => $passengers) {
echo "[ReEngagement] Country: $countryCode — " . count($passengers) . " passengers.\n";
// ─── رسالة Gemini مخصصة لدولة واحدة ───────────────────
$regionName = match($countryCode) {
'JO' => 'Amman',
'SY' => 'Damascus',
'EG' => 'Cairo',
'IQ' => 'Baghdad',
default => 'your city'
};
// سحب أسعار المنافسين كمرجع للرسالة
$stmtComp = $con->prepare(
"SELECT competitor_name, base_fare FROM competitor_secret_formulas
WHERE country_code = :cc ORDER BY last_updated DESC LIMIT 3"
);
$stmtComp->execute([':cc' => $countryCode]);
$competitors = $stmtComp->fetchAll(PDO::FETCH_ASSOC);
if (empty($competitors)) {
$competitors = [['competitor_name' => 'Market Average', 'base_fare' => 1.0]];
}
$aiCampaign = $geminiService->analyzeMarketAndDraftCampaign(
$competitors,
1.0,
$regionName,
$countryCode
);
// Fallback إذا Gemini ما أعطى فرصة
if (!$aiCampaign || ($aiCampaign['opportunity_detected'] ?? false) !== true) {
$countryEmoji = match($countryCode) { 'JO' => '🇯🇴', 'SY' => '🇸🇾', 'EG' => '🇪🇬', default => '🌍' };
$title = "وينك؟ نفتقدك! {$countryEmoji}";
$body = "مش شايفينك من {$INACTIVE_DAYS} أيام. سيارتك جاهزة وبأرخص سعر — اطلب الآن! 🚗";
} else {
$title = $aiCampaign['push_title'] ?? '🚗 سيرو يفتقدك!';
$body = $aiCampaign['push_body'] ?? "لم تطلب منذ {$INACTIVE_DAYS} أيام — العروض لا تنتظر!";
}
echo "[ReEngagement] Message: [{$title}] {$body}\n";
// ─── إرسال لكل راكب مع anti-spam ───────────────────────
foreach ($passengers as $p) {
$passengerId = $p['passenger_id'];
$redisKey = "reengagement:sent:{$passengerId}";
// Anti-spam: تخطّى إذا أرسلنا له خلال 72 ساعة
if ($redis && $redis->exists($redisKey)) {
$skippedCount++;
continue;
}
$decryptedToken = $encryptionHelper->decryptData($p['token'] ?? '');
if (!$decryptedToken) continue;
$result = sendFCM_Internal(
$decryptedToken,
$title,
$body,
[
'type' => 're_engagement',
'days_inactive' => $INACTIVE_DAYS,
'screen' => 'home',
],
'Marketing',
false,
'ding'
);
// حفظ في Redis لمنع التكرار
if ($redis) {
$redis->setex($redisKey, $REDIS_TTL, '1');
}
// حفظ في جدول notifications الداخلي (يظهر في صفحة الإشعارات)
try {
$ins = $con->prepare(
"INSERT INTO notifications (title, body, passenger_id)
VALUES (:title, :body, :pid)"
);
$ins->execute([':title' => $title, ':body' => $body, ':pid' => $passengerId]);
} catch (Exception $e) {
error_log("[ReEngagement] DB insert error: " . $e->getMessage());
}
$sentCount++;
}
}
echo "[ReEngagement] Done ✅ — Sent: {$sentCount}, Skipped (anti-spam): {$skippedCount}\n";
?>
+257
View File
@@ -0,0 +1,257 @@
<?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');
$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(pickup_lat / :grid) * :grid AS cell_lat,
ROUND(pickup_lng / :grid) * :grid AS cell_lng,
country_code,
COUNT(*) AS demand_score
FROM rides
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 pickup_lat != 0
AND pickup_lng != 0
GROUP BY cell_lat, cell_lng, country_code
HAVING demand_score >= 2
ORDER BY demand_score DESC
LIMIT :top
";
try {
$stmtH = $con->prepare($sqlHistory);
$stmtH->bindValue(':grid', $GRID_SIZE, PDO::PARAM_STR);
$stmtH->bindValue(':next_hour', $targetHourNext, PDO::PARAM_INT);
$stmtH->bindValue(':dow', $currentDow, PDO::PARAM_INT);
$stmtH->bindValue(':weeks', $WEEKS_HISTORY, PDO::PARAM_INT);
$stmtH->bindValue(':top', $TOP_ZONES * 3, PDO::PARAM_INT); // نأخذ أكثر لنصفيها لاحقاً
$stmtH->execute();
$hotZones = $stmtH->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";
?>
+2 -2
View File
@@ -16,8 +16,8 @@ $sql = "
SELECT p.id as passenger_id, t.token
FROM passengers p
JOIN tokens t ON p.id = t.passengerID
LEFT JOIN passenger_opening_locations pol ON p.id = pol.start_location
WHERE (pol.date IS NULL OR pol.date < DATE_SUB(NOW(), INTERVAL 24 HOUR))
LEFT JOIN passenger_opening_locations pol ON p.id = pol.passenger_id
WHERE (pol.created_at IS NULL OR pol.created_at < DATE_SUB(NOW(), INTERVAL 24 HOUR))
GROUP BY p.id, t.token
LIMIT 500 -- Batch size to avoid overloading the server or getting rate-limited
";