146 lines
7.7 KiB
PHP
146 lines
7.7 KiB
PHP
<?php
|
||
/**
|
||
* cron_driver_score.php
|
||
* ─────────────────────────────────────────────────────────────
|
||
* يغذّي إشارات ترتيب محرك الإسناد في مفاتيح driver:score:* على Redis.
|
||
*
|
||
* بدون هذا الكرون يعمل المحرك بالقرب وملاءمة الوجهة فقط، وتُحسب بقية
|
||
* المكوّنات محايدة (٠٫٥) — أي أن ٥٠٪ من وزن الترتيب معطّل عملياً:
|
||
* • معدّل القبول ٢٠٪ ← يُحسب هنا
|
||
* • التقييم ١٥٪ ← يُحسب هنا
|
||
* • مدة الخمول ١٥٪ ← يختمها finish_ride_updates.php، لا نلمسها
|
||
*
|
||
* ── معدّل القبول ──
|
||
* لا يوجد في MySQL أي سجل لعرضٍ لم يُقبل: driver_orders يسجّل المقبول
|
||
* فقط. لذلك يُعدّ العرض في السوكِت (حقل offers) والقبول في acceptRide.php
|
||
* (حقل accepts)، وهذا الكرون يحوّلهما لنسبة.
|
||
*
|
||
* الاضمحلال: نضرب العدّادين بمعامل كل تشغيل، فتصير النسبة نافذة متحرّكة
|
||
* تعكس السلوك الأخير لا سجلّ العمر كله. سائق أصلح سلوكه يتعافى خلال أيام
|
||
* بدل أن يظل مسجوناً في ماضيه.
|
||
*
|
||
* تنعيم Laplace: سائق عُرض عليه عرضان وقَبِل واحداً لا يستحق ٥٠٪ بثقة
|
||
* كاملة. نضيف عروضاً وهمية محايدة فتقترب نسبته من المحايد حتى تتراكم
|
||
* بيانات كافية.
|
||
*
|
||
* جدولة مقترحة (crontab): كل ربع ساعة — الإشارات بطيئة التغيّر ولا تستحق أكثر.
|
||
* الحقل الأول في السطر: الشرطة المائلة ثم 15
|
||
* ثم: docker compose exec -T php php /var/www/backend/bot/cron_driver_score.php
|
||
*
|
||
* ⚠️ يجب أن يعمل داخل حاوية php: Redis لا ينشر منافذ، فتشغيله على المضيف
|
||
* يعطي $redisLocation = null ويخرج بلا عمل.
|
||
*/
|
||
|
||
require_once __DIR__ . '/../core/bootstrap.php';
|
||
|
||
// ── معاملات المعايرة ──
|
||
const SCORE_DECAY = 0.85; // ما يبقى من العدّادات كل تشغيل
|
||
const SCORE_PRIOR_OFFERS = 4.0; // عروض وهمية للتنعيم
|
||
const SCORE_PRIOR_ACCEPTS = 2.0; // منها مقبولة — أي محايد ٠٫٥
|
||
const SCORE_KEY_TTL = 2592000; // 30 يوماً: سائق اختفى يُنسى وحده
|
||
|
||
if (!isset($redisLocation) || !$redisLocation) {
|
||
fwrite(STDERR, "[driver_score] Redis غير متاح — هل يعمل الكرون داخل حاوية php؟\n");
|
||
exit(1);
|
||
}
|
||
|
||
try {
|
||
$con = Database::get('main');
|
||
} catch (Exception $e) {
|
||
fwrite(STDERR, "[driver_score] DB unavailable: " . $e->getMessage() . "\n");
|
||
exit(1);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// ١) التقييمات — استعلام واحد مجمّع لكل السائقين
|
||
// نافذة ٩٠ يوماً: تقييم قبل سنة لا يصف السائق اليوم.
|
||
// ═══════════════════════════════════════════════════════════════
|
||
$ratings = [];
|
||
try {
|
||
$stmt = $con->query("
|
||
SELECT driver_id, AVG(rating) AS avg_rating, COUNT(*) AS cnt
|
||
FROM ratingDriver
|
||
WHERE driver_id IS NOT NULL
|
||
AND rating IS NOT NULL
|
||
AND created_at >= NOW() - INTERVAL 90 DAY
|
||
GROUP BY driver_id
|
||
");
|
||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||
// تقييم واحد أو اثنان لا يكفيان لحكم — ننعّمه نحو المحايد (٣٫٥/٥)
|
||
$n = (int) $row['cnt'];
|
||
$avg = (float) $row['avg_rating'];
|
||
$ratings[$row['driver_id']] = round((($avg * $n) + (3.5 * 3)) / ($n + 3), 3);
|
||
}
|
||
} catch (PDOException $e) {
|
||
fwrite(STDERR, "[driver_score] تعذّر جلب التقييمات: " . $e->getMessage() . "\n");
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// ٢) المرور على مفاتيح الدرجات الموجودة
|
||
// scan لا keys: keys تحجب Redis بالكامل على قاعدة كبيرة.
|
||
// ═══════════════════════════════════════════════════════════════
|
||
$processed = 0;
|
||
$iterator = null;
|
||
|
||
// بلا هذا الضبط يرجع phpredis false عند دفعة فارغة فتتوقف الحلقة مبكراً
|
||
$redisLocation->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY);
|
||
|
||
while (($keys = $redisLocation->scan($iterator, 'driver:score:*', 200)) !== false) {
|
||
foreach ($keys as $key) {
|
||
$driverId = substr($key, strlen('driver:score:'));
|
||
if ($driverId === '') continue;
|
||
|
||
try {
|
||
$row = $redisLocation->hGetAll($key) ?: [];
|
||
$offers = (float) ($row['offers'] ?? 0);
|
||
$accepts = (float) ($row['accepts'] ?? 0);
|
||
|
||
// لا يتجاوز المقبول المعروض — يحدث لو أُعيد ضبط أحدهما وحده.
|
||
$accepts = min($accepts, $offers);
|
||
|
||
$rate = ($accepts + SCORE_PRIOR_ACCEPTS) / ($offers + SCORE_PRIOR_OFFERS);
|
||
|
||
$update = ['accept_rate' => round($rate, 4)];
|
||
|
||
if (isset($ratings[$driverId])) {
|
||
$update['rating'] = $ratings[$driverId];
|
||
}
|
||
|
||
// الاضمحلال: نافذة متحرّكة بدل سجل العمر كله.
|
||
$update['offers'] = round($offers * SCORE_DECAY, 3);
|
||
$update['accepts'] = round($accepts * SCORE_DECAY, 3);
|
||
|
||
$redisLocation->hMSet($key, $update);
|
||
$redisLocation->expire($key, SCORE_KEY_TTL);
|
||
$processed++;
|
||
} catch (Throwable $e) {
|
||
fwrite(STDERR, "[driver_score] خطأ على $key: " . $e->getMessage() . "\n");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// ٣) سائقون لهم تقييم ولم يُعرض عليهم شيء بعد
|
||
// بلا هذه الخطوة يظل التقييم معطّلاً حتى أول عرض — أي أن السائق
|
||
// الممتاز الجديد لا يستفيد من تقييمه في أول ترتيب له.
|
||
// ═══════════════════════════════════════════════════════════════
|
||
$seeded = 0;
|
||
foreach ($ratings as $driverId => $rating) {
|
||
$key = "driver:score:$driverId";
|
||
try {
|
||
if (!$redisLocation->exists($key)) {
|
||
$redisLocation->hMSet($key, [
|
||
'rating' => $rating,
|
||
'accept_rate' => round(SCORE_PRIOR_ACCEPTS / SCORE_PRIOR_OFFERS, 4),
|
||
]);
|
||
$redisLocation->expire($key, SCORE_KEY_TTL);
|
||
$seeded++;
|
||
}
|
||
} catch (Throwable $e) {
|
||
// تجاهُل — المرور القادم يعالجه
|
||
}
|
||
}
|
||
|
||
echo "[driver_score] حُدِّث $processed سائق، وأُنشئ $seeded من التقييمات."
|
||
. " تقييمات محمّلة: " . count($ratings) . "\n";
|