Update: 2026-08-07 01:35:27

This commit is contained in:
Hamza-Ayed
2026-08-07 01:35:27 +03:00
parent 0cb1ab0ea3
commit 5b864e30df
11 changed files with 843 additions and 26 deletions
+145
View File
@@ -0,0 +1,145 @@
<?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";