Update: 2026-08-07 01:35:27
This commit is contained in:
@@ -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";
|
||||
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// dispatch_helper.php — طبقة القرار بين إنشاء الرحلة والسوكِت
|
||||
//
|
||||
// الوضع القائم (broadcast): الرحلة تُبَثّ لكل سائق قريب، ويفوز أسرع من
|
||||
// يضغط — لا الأقرب. سباق يخسره تسعة من عشرة.
|
||||
//
|
||||
// الوجهة تبقى ظاهرة كاملة (إحداثيات واسماً) بقرار المالك: التطبيقات ترسم
|
||||
// بها المسار والخريطة، وللسائق حق معرفة وجهته قبل القبول. معالجة الرفض
|
||||
// الانتقائي تتم عبر وزن معدّل القبول في الترتيب أدناه، لا بحجب المعلومة.
|
||||
//
|
||||
// الوضع الجديد (batched): سبق حصري قصير للمرشح الأفضل، ثم إطلاق للجميع.
|
||||
//
|
||||
// قرار مالك المنتج صراحةً: لا سُلّم تنازلي بين المرشحين. سُلّم من ثمانية
|
||||
// مرشحين بمهلة عشر ثوانٍ لكلٍّ يعني انتظاراً قد يبلغ ثمانين ثانية للراكب —
|
||||
// وانتظار الراكب هو المؤشر الذي يقتل المنتج، لا عدالة التوزيع بين
|
||||
// السائقين. التأخير هنا محدود بخمس ثوانٍ ثابتة ومعروفة، ثم يرى الجميع
|
||||
// الرحلة كما في السلوك القديم.
|
||||
//
|
||||
// ⚠️ يلمس أخطر مسار في النظام. لذلك:
|
||||
// • مطفأ افتراضياً — DISPATCH_MODE غير المضبوط = broadcast (السلوك الحالي حرفياً)
|
||||
// • قابل للحصر بمدينة/نوع سيارة عبر DISPATCH_BATCHED_CAR_TYPES
|
||||
// • التراجع فوري: غيّر متغير البيئة وأعد تشغيل php — بلا ترحيل ولا نشر
|
||||
//
|
||||
// لا يضيف هذا الملف أي استعلام MySQL: كل إشارات الترتيب تأتي من Redis أو
|
||||
// من بيانات findBestDrivers() المحمّلة أصلاً. الإشارة الغائبة تُحسب محايدة.
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* مدة السبق الحصري بالثواني قبل الإطلاق للجميع.
|
||||
*
|
||||
* متغيّر لا ثابت عمداً: خمس ثوانٍ قد لا تكفي عملياً — رد فعل الإنسان على
|
||||
* إشعار يستغرق ٢-٥ ثوانٍ، فقد ينتهي السبق قبل أن يرفع السائق هاتفه فيصير
|
||||
* المحرك بلا أثر. ابدأ بخمس، وارفعها إن أظهرت السجلات أن السائق الأول
|
||||
* نادراً ما يقبل خلالها — بلا تعديل كود.
|
||||
*/
|
||||
function dispatchHeadStartSeconds(): int
|
||||
{
|
||||
$v = (int) (getenv('DISPATCH_HEAD_START_SECONDS') ?: 5);
|
||||
// سقف صلب: أي رقم أعلى يعيد مشكلة انتظار الراكب التي أُلغي السُّلّم بسببها.
|
||||
return max(1, min(15, $v));
|
||||
}
|
||||
|
||||
// عدد المرشحين المحفوظين. ليس سُلّماً: يُستخدم فقط لاختيار صاحب السبق إن
|
||||
// كان الأول غير متصل لحظتها — الانتقال بينهم فوري بلا أي انتظار إضافي.
|
||||
const DISPATCH_MAX_CANDIDATES = 3;
|
||||
|
||||
/**
|
||||
* هل نستخدم الإسناد بالدفعات لهذه الرحلة؟
|
||||
*
|
||||
* DISPATCH_MODE=batched يفعّله، وأي قيمة أخرى (أو غيابها) تبقي البثّ الحر.
|
||||
* DISPATCH_BATCHED_CAR_TYPES يحصره بأنواع سيارات بعينها للتجربة التدريجية،
|
||||
* مثلاً "Speed" لتجريبه على نوع واحد قبل التعميم. فارغ = كل الأنواع.
|
||||
*/
|
||||
function dispatchIsBatched(string $carType): bool
|
||||
{
|
||||
if (strtolower((string) getenv('DISPATCH_MODE')) !== 'batched') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = trim((string) getenv('DISPATCH_BATCHED_CAR_TYPES'));
|
||||
if ($allowed === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$list = array_filter(array_map('trim', explode(',', $allowed)));
|
||||
return in_array($carType, $list, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* درجة المرشح — كلما ارتفعت كان أولى بالعرض.
|
||||
*
|
||||
* الأوزان مقصودة كنقطة انطلاق قابلة للمعايرة بعد أول أسبوع تشغيل، لا
|
||||
* كأرقام نهائية. كل مكوّن مُطبَّع إلى [0,1] فيبقى المجموع مفهوماً.
|
||||
*
|
||||
* @param array $driver صف من findBestDrivers()
|
||||
* @param array $signals إشارات مقروءة من Redis (قد تكون فارغة)
|
||||
*/
|
||||
function dispatchScoreDriver(array $driver, array $signals): float
|
||||
{
|
||||
// ── ١. القرب (40%) — بديل عملي عن زمن الوصول الفعلي.
|
||||
// المسافة الهوائية ليست ETA حقيقياً، لكنها الإشارة الوحيدة المتاحة بلا
|
||||
// نداء OSRM لكل مرشح. ترقية هذا إلى ETA حقيقي هي أول تحسين بعد الإطلاق.
|
||||
$distanceKm = (float) ($driver['distance_km'] ?? 999);
|
||||
$proximity = $distanceKm >= 10.0 ? 0.0 : (1.0 - ($distanceKm / 10.0));
|
||||
|
||||
// ── ٢. معدّل القبول التاريخي (20%) — من Redis، محايد إن غاب.
|
||||
$acceptRate = isset($signals['accept_rate'])
|
||||
? max(0.0, min(1.0, (float) $signals['accept_rate']))
|
||||
: 0.5;
|
||||
|
||||
// ── ٣. التقييم (15%) — من ٥، محايد إن غاب.
|
||||
$rating = isset($signals['rating'])
|
||||
? max(0.0, min(1.0, ((float) $signals['rating']) / 5.0))
|
||||
: 0.5;
|
||||
|
||||
// ── ٤. مدة الخمول (15%) — إنصاف: من انتظر أطول يُقدَّم.
|
||||
// يُشبَع عند ٣٠ دقيقة حتى لا يحتكر سائق نائم كل الرحلات.
|
||||
$idleSeconds = (int) ($signals['idle_seconds'] ?? 0);
|
||||
$idle = $idleSeconds <= 0 ? 0.5 : min(1.0, $idleSeconds / 1800.0);
|
||||
|
||||
// ── ٥. ملاءمة الوجهة (10%) — السائق الذي سجّل وجهة توافق مسار الرحلة.
|
||||
// findBestDrivers يستبعد من وجهته أبعد من ٥كم، فوجود الوجهة هنا يعني
|
||||
// توافقاً مؤكداً لا مجرد احتمال.
|
||||
$destinationFit = !empty($driver['has_destination']) ? 1.0 : 0.5;
|
||||
|
||||
return round(
|
||||
(0.40 * $proximity)
|
||||
+ (0.20 * $acceptRate)
|
||||
+ (0.15 * $rating)
|
||||
+ (0.15 * $idle)
|
||||
+ (0.10 * $destinationFit),
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* يقرأ إشارات الترتيب لكل السائقين المرشحين بنداء Redis واحد (pipeline).
|
||||
*
|
||||
* المفاتيح تُغذّى من كرونات قائمة أو تُترك فارغة — الغياب لا يكسر شيئاً،
|
||||
* بل يجعل المكوّن محايداً. هذا يسمح بإطلاق المحرك قبل بناء مغذّياته.
|
||||
*/
|
||||
function dispatchReadSignals($redisLocation, array $driverIds): array
|
||||
{
|
||||
$signals = [];
|
||||
if (!$redisLocation || empty($driverIds)) {
|
||||
return $signals;
|
||||
}
|
||||
|
||||
try {
|
||||
$pipe = $redisLocation->multi(Redis::PIPELINE);
|
||||
foreach ($driverIds as $id) {
|
||||
$pipe->hGetAll("driver:score:$id");
|
||||
}
|
||||
$rows = $pipe->exec() ?: [];
|
||||
|
||||
foreach ($driverIds as $i => $id) {
|
||||
$row = $rows[$i] ?? [];
|
||||
if (!is_array($row) || empty($row)) {
|
||||
continue;
|
||||
}
|
||||
$signals[$id] = [
|
||||
'accept_rate' => $row['accept_rate'] ?? null,
|
||||
'rating' => $row['rating'] ?? null,
|
||||
// آخر لحظة أنهى فيها رحلة — نحوّلها لمدة خمول.
|
||||
'idle_seconds' => isset($row['last_ride_end'])
|
||||
? max(0, time() - (int) $row['last_ride_end'])
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("[dispatch] تعذّرت قراءة إشارات الترتيب: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $signals;
|
||||
}
|
||||
|
||||
/**
|
||||
* يرتّب المرشحين ويسلّمهم للسوكِت ليمنح الأفضلَ سبقاً حصرياً قصيراً.
|
||||
*
|
||||
* @return bool true إن بدأ الإسناد بالدفعات، false إن تعذّر (فيجب أن يقع
|
||||
* المستدعي على البثّ الحر — لا تُترك رحلة بلا إسناد).
|
||||
*/
|
||||
function dispatchStartBatched(
|
||||
$redisLocation,
|
||||
$rideId,
|
||||
array $driversData,
|
||||
array $marketPayload
|
||||
): bool {
|
||||
if (!$redisLocation || empty($driversData)) {
|
||||
error_log("[dispatch] لا مرشحين أو Redis غير متاح RideID=$rideId"
|
||||
. " — السقوط للبثّ الحر");
|
||||
return false;
|
||||
}
|
||||
|
||||
$driverIds = array_values(array_filter(array_column($driversData, 'driver_id')));
|
||||
$signals = dispatchReadSignals($redisLocation, $driverIds);
|
||||
|
||||
// الترتيب بالدرجة تنازلياً
|
||||
$ranked = [];
|
||||
foreach ($driversData as $driver) {
|
||||
$id = $driver['driver_id'] ?? null;
|
||||
if (!$id) {
|
||||
continue;
|
||||
}
|
||||
$ranked[] = [
|
||||
'driver_id' => (string) $id,
|
||||
'score' => dispatchScoreDriver($driver, $signals[$id] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
usort($ranked, fn($a, $b) => $b['score'] <=> $a['score']);
|
||||
$ranked = array_slice($ranked, 0, DISPATCH_MAX_CANDIDATES);
|
||||
|
||||
if (empty($ranked)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$queueKey = "ride:$rideId:dispatch_queue";
|
||||
$redisLocation->del($queueKey);
|
||||
foreach ($ranked as $r) {
|
||||
$redisLocation->rPush($queueKey, $r['driver_id']);
|
||||
}
|
||||
// عمر قصير: السبق خمس ثوانٍ ثم يُطلق للجميع، فلا معنى لبقاء
|
||||
// الطابور بعدها. هامش سخي للمهل الشبكية فقط.
|
||||
$ttl = dispatchHeadStartSeconds() + 30;
|
||||
$redisLocation->expire($queueKey, $ttl);
|
||||
|
||||
// الحمولة التي سيراها المرشح — بلا وجهة نهائية (انظر أدناه).
|
||||
$redisLocation->setex(
|
||||
"ride:$rideId:dispatch_payload",
|
||||
$ttl,
|
||||
// الحمولة كاملة بالإحداثيات واسم الوجهة — قرار المالك: السائق
|
||||
// يحتاجها لرسم البوليلاين والخريطة، ومن حقه معرفة وجهته قبل
|
||||
// القبول (منطقة خطرة، بعيدة، أو لا يريدها). لا إخفاء.
|
||||
json_encode($marketPayload, JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
error_log("[dispatch] تعذّر بناء الطابور RideID=$rideId: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
error_log("[dispatch] RideID=$rideId — سبق " . dispatchHeadStartSeconds()
|
||||
. "ث للسائق " . $ranked[0]['driver_id']
|
||||
. " (درجة " . $ranked[0]['score'] . ") ثم إطلاق للجميع");
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -69,6 +69,32 @@ try {
|
||||
error_log("[accept_ride] تعذّر فحص الإيقاف: " . $eBlock->getMessage());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP A0b — حارس الحصرية في الإسناد بالدفعات
|
||||
//
|
||||
// بدون هذا الفحص تبقى الحصرية شكلية: سائق تلقّى العرض سابقاً (أو صاغ
|
||||
// الطلب يدوياً) يستطيع القبول خارج دوره فيعود السباق كما كان.
|
||||
//
|
||||
// وجود المفتاح هو ما يحدد الوضع: البثّ الحر لا يكتبه أصلاً، فالفحص
|
||||
// يمرّ بلا أثر — لا حاجة لقراءة DISPATCH_MODE هنا.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$currentOffer = $redisLocation->get("ride:$rideId:current_offer");
|
||||
if ($currentOffer !== false && $currentOffer !== null
|
||||
&& (string) $currentOffer !== (string) $driverId) {
|
||||
error_log("[accept_ride] DriverID=$driverId حاول قبول RideID=$rideId"
|
||||
. " والعرض الحالي لـ $currentOffer — رُفض");
|
||||
printFailure("Ride not available");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $eOffer) {
|
||||
// فشل الفحص لا يمنع القبول — القفل التفاؤلي في STEP A يبقى الضامن
|
||||
// ضد الازدواج، وأسوأ نتيجة هنا هي عودة سلوك البثّ الحر لهذه الرحلة.
|
||||
error_log("[accept_ride] تعذّر فحص حصرية العرض: " . $eOffer->getMessage());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP A — القفل على ride DB (المرجع الأساسي)
|
||||
// Optimistic lock: نغير فقط إذا status لا يزال 'waiting' أو 'wait'
|
||||
@@ -93,6 +119,17 @@ try {
|
||||
|
||||
error_log("[accept_ride] ride DB locked. RideID=$rideId → DriverID=$driverId");
|
||||
|
||||
// عدّاد القبول — بسط معدّل القبول الذي يرتّب به محرك الإسناد.
|
||||
// يُعدّ هنا بعد نجاح القفل حصراً: العدّ قبله كان سيحسب للخاسرين في
|
||||
// السباق قبولاً لم يحدث.
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$redisLocation->hIncrBy("driver:score:$driverId", 'accepts', 1);
|
||||
}
|
||||
} catch (Throwable $eScore) {
|
||||
error_log("[accept_ride] تعذّر تحديث عدّاد القبول: " . $eScore->getMessage());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP B — تزامن primary DB (بعد نجاح القفل)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -16,22 +16,20 @@ try {
|
||||
// =================================================================================
|
||||
// 🛠️ دالة مساعدة: إرسال الرحلة لسوق السائقين (Marketplace Broadcast)
|
||||
// =================================================================================
|
||||
function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketData = []) {
|
||||
$url = getenv('LOCATION_SOCKET_URL') ?: 'http://socket_driver:2021';
|
||||
if (strpos($url, 'localhost') !== false || strpos($url, '127.0.0.1') !== false) {
|
||||
if (file_exists('/.dockerenv')) {
|
||||
$url = str_replace(['localhost', '127.0.0.1'], 'socket_driver', $url);
|
||||
}
|
||||
}
|
||||
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
|
||||
// ⚠️ هذه الحمولة تُبَثّ لكل سائق قريب (وليس للفائز فقط) — لا نضع فيها أي
|
||||
// بيانات راكب حسّاسة (هاتف/إيميل/FCM token). ما ينقص السائق من تفاصيل
|
||||
// الراكب يجلبه بعد نجاح القبول، والسيرفر يحلّ التوكن بنفسه في acceptRide.php.
|
||||
//
|
||||
// أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر
|
||||
// رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح — أي حقل ناقص
|
||||
// يصل للسيرفر لاحقاً كنص "null".
|
||||
$marketPayload = array_merge([
|
||||
/**
|
||||
* حمولة الرحلة كما يراها السائق قبل القبول.
|
||||
*
|
||||
* مشتركة بين البثّ الحر والإسناد بالدفعات عمداً: أي حقل يظهر في أحدهما
|
||||
* ولا يظهر في الآخر يصل للسيرفر لاحقاً كنص "null".
|
||||
*
|
||||
* ⚠️ لا تضع هنا أي بيانات راكب حسّاسة (هاتف/إيميل/FCM token) — هذه الحمولة
|
||||
* تصل سائقين لن يقودوا الرحلة. التفاصيل تُجلب بعد نجاح القبول.
|
||||
*
|
||||
* أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر
|
||||
* رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح.
|
||||
*/
|
||||
function buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData = []) {
|
||||
return array_merge([
|
||||
'id' => (string)$rideId,
|
||||
'start_lat' => $lat,
|
||||
'start_lng' => $lng,
|
||||
@@ -48,6 +46,52 @@ function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketDa
|
||||
'passengerRate' => $payloadData[33],
|
||||
'passengerId' => $payloadData[7],
|
||||
], $extraMarketData);
|
||||
}
|
||||
|
||||
/**
|
||||
* يوقظ العرض المتسلسل في السوكِت بعد أن بنى dispatchStartBatched الطابور.
|
||||
* السوكِت هو صاحب المؤقّت لأنه العملية الوحيدة طويلة العمر (Workerman
|
||||
* Timer) — كرون كل دقيقة لا يصلح لمهلة عشر ثوانٍ.
|
||||
*/
|
||||
function notifySocketStartBatchedDispatch($rideId) {
|
||||
$url = getenv('LOCATION_SOCKET_URL') ?: 'http://socket_driver:2021';
|
||||
if (file_exists('/.dockerenv')) {
|
||||
$url = str_replace(['localhost', '127.0.0.1'], 'socket_driver', $url);
|
||||
}
|
||||
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'action' => 'start_batched_dispatch',
|
||||
'ride_id' => $rideId,
|
||||
]));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT_MS, 2000);
|
||||
if ($INTERNAL_KEY) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-internal-key: $INTERNAL_KEY"]);
|
||||
}
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketData = []) {
|
||||
$url = getenv('LOCATION_SOCKET_URL') ?: 'http://socket_driver:2021';
|
||||
if (strpos($url, 'localhost') !== false || strpos($url, '127.0.0.1') !== false) {
|
||||
if (file_exists('/.dockerenv')) {
|
||||
$url = str_replace(['localhost', '127.0.0.1'], 'socket_driver', $url);
|
||||
}
|
||||
}
|
||||
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
|
||||
// ⚠️ هذه الحمولة تُبَثّ لكل سائق قريب (وليس للفائز فقط) — لا نضع فيها أي
|
||||
// بيانات راكب حسّاسة (هاتف/إيميل/FCM token). ما ينقص السائق من تفاصيل
|
||||
// الراكب يجلبه بعد نجاح القبول، والسيرفر يحلّ التوكن بنفسه في acceptRide.php.
|
||||
//
|
||||
// أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر
|
||||
// رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح — أي حقل ناقص
|
||||
// يصل للسيرفر لاحقاً كنص "null".
|
||||
$marketPayload = buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData);
|
||||
|
||||
$postData = [
|
||||
'action' => 'market_new_ride',
|
||||
@@ -428,15 +472,44 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($driversData)) {
|
||||
dispatchRideToDrivers($driversData, $insertedId, $payload, $start_name_loc, $encryptionHelper, $extraDispatchData);
|
||||
error_log("[add_ride] Dispatched RideID=$insertedId to " . count($driversData) . " drivers.");
|
||||
} else {
|
||||
error_log("[add_ride] No direct drivers found for RideID=$insertedId — market only.");
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// الإسناد — بالدفعات أو بالبثّ الحر
|
||||
//
|
||||
// batched: عرض حصري متسلسل على مرشحين مرتبين. لا بثّ للسوق، ولا
|
||||
// إشعار جماعي — وإلا لضاع معنى الحصرية.
|
||||
// broadcast (الافتراضي): السلوك القائم حرفياً بلا أي تغيير.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
require_once __DIR__ . '/../dispatch/dispatch_helper.php';
|
||||
|
||||
$batchedStarted = false;
|
||||
if (dispatchIsBatched((string) ($payload[31] ?? ''))) {
|
||||
$batchedStarted = dispatchStartBatched(
|
||||
$redisLocation ?? null,
|
||||
$insertedId,
|
||||
$driversData ?: [],
|
||||
buildMarketPayload($insertedId, $startLat, $startLng, $payload, $extraDispatchData)
|
||||
);
|
||||
|
||||
if ($batchedStarted) {
|
||||
notifySocketStartBatchedDispatch($insertedId);
|
||||
error_log("[add_ride] RideID=$insertedId أُسند بالدفعات.");
|
||||
} else {
|
||||
error_log("[add_ride] تعذّر الإسناد بالدفعات RideID=$insertedId"
|
||||
. " — السقوط للبثّ الحر.");
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast للـ marketplace دائماً
|
||||
broadcastRideToMarket($insertedId, $startLat, $startLng, $payload, $extraDispatchData);
|
||||
if (!$batchedStarted) {
|
||||
if (!empty($driversData)) {
|
||||
dispatchRideToDrivers($driversData, $insertedId, $payload, $start_name_loc, $encryptionHelper, $extraDispatchData);
|
||||
error_log("[add_ride] Dispatched RideID=$insertedId to " . count($driversData) . " drivers.");
|
||||
} else {
|
||||
error_log("[add_ride] No direct drivers found for RideID=$insertedId — market only.");
|
||||
}
|
||||
|
||||
// Broadcast للـ marketplace دائماً
|
||||
broadcastRideToMarket($insertedId, $startLat, $startLng, $payload, $extraDispatchData);
|
||||
}
|
||||
|
||||
// رد النجاح للتطبيق
|
||||
printSuccess($insertedId);
|
||||
|
||||
@@ -328,6 +328,17 @@ try {
|
||||
'passenger_id' => $passengerId,
|
||||
]);
|
||||
|
||||
// ختم نهاية الرحلة — مقياس الخمول في ترتيب محرك الإسناد (وزن ١٥٪،
|
||||
// إنصافاً لمن انتظر أطول). يُكتب هنا لا عند القبول: السائق يبدأ
|
||||
// "الخمول" حين يفرغ فعلاً، لا حين ينشغل.
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$redisLocation->hSet("driver:score:$driver_id", 'last_ride_end', time());
|
||||
}
|
||||
} catch (Throwable $eScore) {
|
||||
error_log("[finish_ride_updates] تعذّر ختم نهاية الرحلة: " . $eScore->getMessage());
|
||||
}
|
||||
|
||||
// 🔥 [Fix Split-Brain] تحديث القاعدة البعيدة الآن فقط، بعد أن أصبح الدفع
|
||||
// والتحديث المحلي مؤكدَين نجاحهما — يبقي الحالتين متطابقتين دائماً.
|
||||
// فشل هذا التحديث best-effort فقط (لا يُرجع الرحلة المحلية المُنجَزة فعلاً).
|
||||
|
||||
@@ -42,6 +42,114 @@ function logMsg(string $msg): void {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 🎯 الإسناد بالدفعات — سبق حصري قصير ثم إطلاق للجميع
|
||||
//
|
||||
// add_ride.php يرتّب المرشحين في Redis ثم يوقظنا. نمنح الأفضل سبقاً
|
||||
// حصرياً بضع ثوانٍ، وبانتهائه تُطلق الرحلة لكل السائقين المتصلين.
|
||||
//
|
||||
// لا سُلّم تنازلي: سُلّم بين ثمانية مرشحين يعني انتظاراً قد يبلغ ثمانين
|
||||
// ثانية للراكب. هنا التأخير محدود بمدة السبق وحدها، مهما حدث.
|
||||
//
|
||||
// مطفأ ما لم يضبط add_ride.php الطابور (أي ما لم يكن DISPATCH_MODE=batched).
|
||||
// ============================================================
|
||||
function dispatchHeadStart(): int {
|
||||
$v = (int)(getenv('DISPATCH_HEAD_START_SECONDS') ?: 5);
|
||||
return max(1, min(15, $v)); // يطابق dispatchHeadStartSeconds() في dispatch_helper.php
|
||||
}
|
||||
|
||||
/**
|
||||
* يمنح المرشح الأفضل المتصل سبقاً حصرياً، ويجدول الإطلاق للجميع بعده.
|
||||
*
|
||||
* المرور على الطابور هنا ليس سُلّماً: هو اختيار أول مرشح متصل فعلاً.
|
||||
* غير المتصل يُتخطّى فوراً بلا إهدار ثانية واحدة من وقت الراكب.
|
||||
*/
|
||||
function dispatchOfferNext($io, string $rideId): void {
|
||||
global $connectedDrivers;
|
||||
|
||||
$redis = getRedis();
|
||||
if (!$redis) return;
|
||||
|
||||
// الرحلة قد تكون قُبلت أو أُلغيت بين تحديدين للمؤقّت.
|
||||
if (!$redis->exists("ride:$rideId:dispatch_payload")) {
|
||||
dispatchCleanup($redis, $rideId);
|
||||
return;
|
||||
}
|
||||
|
||||
$headStart = dispatchHeadStart();
|
||||
$queueKey = "ride:$rideId:dispatch_queue";
|
||||
|
||||
while (($driverId = $redis->lpop($queueKey)) !== null) {
|
||||
if (!isset($connectedDrivers[$driverId])) {
|
||||
logMsg("⏭️ Ride #$rideId — تخطّي السائق #$driverId (غير متصل)");
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = json_decode((string)$redis->get("ride:$rideId:dispatch_payload"), true) ?: [];
|
||||
|
||||
// وسم العرض: التطبيق يعرضه كـ"طلب خاص" مع عدّاد تنازلي، ليفهم
|
||||
// السائق أنه صاحب الأولوية الآن لا أن الطلب وصله عشوائياً.
|
||||
$payload['offer_type'] = 'exclusive';
|
||||
$payload['offer_expires_in'] = $headStart;
|
||||
|
||||
// رمز الحصرية: acceptRide.php يرفض أي سائق لا يطابقه خلال السبق.
|
||||
// TTL ينهي الحصرية تلقائياً حتى لو تعطّل المؤقّت.
|
||||
$redis->setex("ride:$rideId:current_offer", $headStart, $driverId);
|
||||
$redis->sadd("ride:offered_drivers:$rideId", $driverId);
|
||||
|
||||
// عدّاد العروض — بسط ومقام معدّل القبول. لا يوجد في MySQL أي سجل
|
||||
// لعرضٍ لم يُقبل (driver_orders يسجّل المقبول فقط)، فالعدّ هنا هو
|
||||
// المصدر الوحيد الممكن. cron_driver_score.php يحوّله لمعدّل ويضمحله.
|
||||
$redis->hincrby("driver:score:$driverId", 'offers', 1);
|
||||
$redis->zadd('dispatch:active', time() + $headStart, $rideId);
|
||||
|
||||
$io->to('driver_' . $driverId)->emit('new_ride_request', $payload);
|
||||
logMsg("🎯 Ride #$rideId — سبق {$headStart}ث للسائق #$driverId");
|
||||
return;
|
||||
}
|
||||
|
||||
// لا مرشح متصل إطلاقاً: لا معنى لانتظار أحد — أطلق فوراً.
|
||||
logMsg("⚡ Ride #$rideId — لا مرشح متصل، إطلاق فوري للجميع");
|
||||
dispatchFallbackToBroadcast($io, $redis, $rideId);
|
||||
}
|
||||
|
||||
/**
|
||||
* الإطلاق للجميع بعد انتهاء السبق — أو فوراً إن لم يكن هناك مرشح متصل.
|
||||
* هذا هو السلوك القديم نفسه، فأسوأ حالة في الوضع الجديد = الوضع القديم.
|
||||
* الحمولة كاملة بالوجهة والإحداثيات — نفس ما يراه صاحب السبق.
|
||||
*/
|
||||
function dispatchFallbackToBroadcast($io, $redis, string $rideId): void {
|
||||
$payload = json_decode((string)$redis->get("ride:$rideId:dispatch_payload"), true) ?: [];
|
||||
dispatchCleanup($redis, $rideId);
|
||||
|
||||
if (empty($payload)) {
|
||||
logMsg("⚠️ Ride #$rideId بلا حمولة — تعذّر الإطلاق");
|
||||
return;
|
||||
}
|
||||
|
||||
// انتهى السبق: الطلب صار عاماً للجميع. غياب الوسم في المسارات
|
||||
// القديمة يعامله التطبيق كعام أيضاً، فلا حاجة لتعديلها.
|
||||
$payload['offer_type'] = 'public';
|
||||
unset($payload['offer_expires_in']);
|
||||
|
||||
global $connectedDrivers;
|
||||
$count = 0;
|
||||
foreach (array_keys($connectedDrivers) as $driverId) {
|
||||
$io->to('driver_' . $driverId)->emit('market_new_ride', $payload);
|
||||
$count++;
|
||||
}
|
||||
logMsg("📢 Ride #$rideId — انتهى السبق، أُطلقت لـ $count سائق");
|
||||
}
|
||||
|
||||
/** يمسح كل مفاتيح الإسناد بالدفعات لرحلة انتهت (قُبلت أو أُلغيت أو نفدت). */
|
||||
function dispatchCleanup($redis, string $rideId): void {
|
||||
if (!$redis) return;
|
||||
$redis->del("ride:$rideId:dispatch_queue");
|
||||
$redis->del("ride:$rideId:dispatch_payload");
|
||||
$redis->del("ride:$rideId:current_offer");
|
||||
$redis->zrem('dispatch:active', $rideId);
|
||||
}
|
||||
|
||||
function loadEnvironment(string $filePath): void {
|
||||
if (!file_exists($filePath)) {
|
||||
logMsg("⚠️ .env not found: $filePath");
|
||||
@@ -315,6 +423,30 @@ $io = new SocketIO(2020);
|
||||
// ============================================================
|
||||
$io->on('workerStart', function () use ($io, $INTERNAL_KEY) {
|
||||
|
||||
// 🎯 0. مقدِّم طابور الإسناد بالدفعات
|
||||
// مؤقّت واحد عام لكل الرحلات — لا مؤقّت لكل رحلة، فمئة رحلة متزامنة
|
||||
// لا تنتج مئة مؤقّت. يمرّ كل ثانية على الرحلات التي انتهى سبقها.
|
||||
// zset مرتّب بلحظة الانتهاء، فالمرور يلمس المستحقّ فقط لا كل الرحلات.
|
||||
Timer::add(1, function() use ($io) {
|
||||
$redis = getRedis();
|
||||
if (!$redis) return;
|
||||
|
||||
try {
|
||||
$due = $redis->zrangebyscore('dispatch:active', 0, time());
|
||||
foreach ($due as $rideId) {
|
||||
// وجود current_offer يعني أن المهلة لم تنتهِ فعلاً بعد
|
||||
// (فارق تقريب بين score وTTL) — نتركها للمرور التالي.
|
||||
if ($redis->exists("ride:$rideId:current_offer")) continue;
|
||||
|
||||
$redis->zrem('dispatch:active', $rideId);
|
||||
logMsg("⏱️ Ride #$rideId — انتهى السبق، إطلاق للجميع");
|
||||
dispatchFallbackToBroadcast($io, $redis, (string)$rideId);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
logMsg('⚠️ Dispatch sweeper error: ' . $e->getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
// 🚀 1. Redis Pipeline Batch Processor (Level 2)
|
||||
// يعمل كل نصف ثانية، يجمع كل الأوامر ويرسلها لـ Redis دفعة واحدة
|
||||
Timer::add(REDIS_BATCH_INTERVAL, function() {
|
||||
@@ -501,6 +633,17 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) {
|
||||
);
|
||||
$connection->send(json_encode($results));
|
||||
|
||||
// ── 3c. Start Batched Dispatch ────────────────────────
|
||||
// يبنيه add_ride.php في Redis ثم يوقظنا هنا. نحن أصحاب المؤقّت
|
||||
// لأننا العملية الوحيدة طويلة العمر — كرون كل دقيقة لا يخدم
|
||||
// مهلة عشر ثوانٍ.
|
||||
} elseif ($action === 'start_batched_dispatch') {
|
||||
$rideId = $post['ride_id'] ?? null;
|
||||
if (!$rideId) { $connection->send('Error: Missing ride_id'); return; }
|
||||
|
||||
dispatchOfferNext($io, (string)$rideId);
|
||||
$connection->send('OK');
|
||||
|
||||
// ── 4. Ride Taken ─────────────────────────────────────
|
||||
} elseif ($action === 'ride_taken_event') {
|
||||
$rideId = $post['ride_id'] ?? null;
|
||||
@@ -519,6 +662,7 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) {
|
||||
}
|
||||
$redis->del($offeredSetKey);
|
||||
$redis->del("ride:offer:$rideId");
|
||||
dispatchCleanup($redis, (string)$rideId);
|
||||
}
|
||||
|
||||
$io->emit('ride_taken', [
|
||||
@@ -551,6 +695,7 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY) {
|
||||
}
|
||||
$redis->del($offeredSetKey);
|
||||
$redis->del("ride:offer:$rideId");
|
||||
dispatchCleanup($redis, (string)$rideId);
|
||||
}
|
||||
|
||||
// نوحّد قائمة من يجب إشعارهم: السائق الحالي (إن وُجد) + كل من عُرضت عليهم الرحلة
|
||||
|
||||
@@ -49,6 +49,21 @@ class OrderRequestController extends GetxController
|
||||
List<dynamic>? myList;
|
||||
Map<dynamic, dynamic>? myMapData;
|
||||
|
||||
/// نوع الطلب: 'exclusive' خلال ثواني السبق الحصري، 'public' بعد إطلاقه
|
||||
/// للجميع، و null في وضع البثّ الحر (DISPATCH_MODE غير مضبوط) — عندها
|
||||
/// لا تُعرض أي شارة.
|
||||
///
|
||||
/// المصدر: حقل offer_type في حمولة السوكِت (new_ride_request /
|
||||
/// market_new_ride). الطلبات القادمة عبر FCM أو القائمة المسبقة لا تحمله.
|
||||
Map<String, dynamic>? get offerInfo {
|
||||
final t = myMapData?['offer_type'];
|
||||
if (t == null) return null;
|
||||
return {
|
||||
'offer_type': t.toString(),
|
||||
'offer_expires_in': myMapData?['offer_expires_in'],
|
||||
};
|
||||
}
|
||||
|
||||
IntaleqMapController? mapController;
|
||||
|
||||
// الإحداثيات (أزلنا late لتجنب الأخطاء القاتلة)
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
import 'package:siro_driver/constant/api_key.dart';
|
||||
import 'package:siro_driver/constant/colors.dart';
|
||||
import 'package:siro_driver/controller/home/captin/order_request_controller.dart';
|
||||
import 'package:siro_driver/views/widgets/offer_type_badge.dart';
|
||||
import 'package:siro_driver/constant/currency.dart';
|
||||
import 'package:siro_driver/views/widgets/driver_earnings_badge.dart';
|
||||
|
||||
@@ -109,6 +110,11 @@ class OrderRequestPage extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// شارة الطلب الخاص/العام — تختفي في وضع البثّ الحر
|
||||
if (controller.offerInfo != null) ...[
|
||||
OfferTypeBadge(rideInfo: controller.offerInfo!),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
const Icon(Icons.near_me,
|
||||
color: Colors.amber, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
/// شارة نوع الطلب: خاص أم عام.
|
||||
///
|
||||
/// الخادم يمنح السائق الأفضل ترتيباً سبقاً حصرياً بضع ثوانٍ قبل أن يُطلق
|
||||
/// الطلب للجميع (DISPATCH_MODE=batched). بدون هذه الشارة يبدو الفرق
|
||||
/// عشوائياً للسائق؛ معها يفهم أنه صاحب الأولوية الآن وأن التأخير يكلّفه
|
||||
/// الطلب.
|
||||
///
|
||||
/// المصدر: حقل `offer_type` في حمولة السوكِت — 'exclusive' أو 'public'.
|
||||
/// غيابه يعني طلباً عاماً (كل المسارات القديمة والبثّ الحر)، فلا تظهر
|
||||
/// الشارة إطلاقاً في وضع البثّ العادي.
|
||||
class OfferTypeBadge extends StatelessWidget {
|
||||
final Map rideInfo;
|
||||
|
||||
const OfferTypeBadge({Key? key, required this.rideInfo}) : super(key: key);
|
||||
|
||||
bool get _isExclusive =>
|
||||
rideInfo['offer_type']?.toString() == 'exclusive';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// لا شارة في الوضع العادي — لا نضيف ضجيجاً بصرياً بلا معنى.
|
||||
if (rideInfo['offer_type'] == null) return const SizedBox.shrink();
|
||||
|
||||
final Color color = _isExclusive ? Colors.amber.shade800 : Colors.blueGrey;
|
||||
final IconData icon = _isExclusive ? Icons.star_rounded : Icons.public;
|
||||
final String label = _isExclusive ? 'طلب خاص لك'.tr : 'طلب عام'.tr;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: color.withOpacity(0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+88
-3
@@ -44,6 +44,7 @@ class TripOverlayService : Service() {
|
||||
private var windowManager: WindowManager? = null
|
||||
private var overlayView: View? = null
|
||||
private var countDownTimer: CountDownTimer? = null
|
||||
private var exclusivityTimer: CountDownTimer? = null
|
||||
private var currentTripId: String = ""
|
||||
private var currentFoodOrderId: String = ""
|
||||
// نوع النافذة المعروضة الآن — يحدّد لمن نُرسل نتيجة القبول/الرفض
|
||||
@@ -79,6 +80,7 @@ class TripOverlayService : Service() {
|
||||
override fun onDestroy() {
|
||||
removeOverlayView()
|
||||
countDownTimer?.cancel()
|
||||
exclusivityTimer?.cancel()
|
||||
stopSound() // 🔴 إيقاف الصوت عند التدمير
|
||||
isRunning = false
|
||||
super.onDestroy()
|
||||
@@ -176,6 +178,11 @@ class TripOverlayService : Service() {
|
||||
isRunning = true
|
||||
playSound() // 🔴 تشغيل الصوت
|
||||
startCountdown(autoCloseSeconds)
|
||||
// بعد العدّاد الرئيسي لا قبله: مؤقّتان مستقلان، والحصرية تنتهي
|
||||
// قبل النافذة بكثير فتقلب الشارة وحدها دون إغلاقها.
|
||||
if (tripData.offerType == "exclusive") {
|
||||
startExclusivityCountdown(tripData.offerExpiresIn)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
stopSelf()
|
||||
}
|
||||
@@ -430,6 +437,7 @@ class TripOverlayService : Service() {
|
||||
private fun onFoodOrderAccepted() {
|
||||
stopSound()
|
||||
countDownTimer?.cancel()
|
||||
exclusivityTimer?.cancel()
|
||||
TripOverlayPlugin.notifyFoodOrderAccepted(currentFoodOrderId)
|
||||
bringAppToForeground()
|
||||
removeOverlayView()
|
||||
@@ -553,6 +561,26 @@ class TripOverlayService : Service() {
|
||||
|
||||
detailsContainer.addView(divider(ctx, 30))
|
||||
|
||||
// ── 3.5 شارة نوع الطلب: خاص أم عام ──
|
||||
// تظهر فقط حين يرسل الخادم offerType (أي في وضع الإسناد بالدفعات).
|
||||
// في البثّ الحر تبقى مخفية تماماً فلا تضيف ضجيجاً بلا معنى.
|
||||
val offerBadge =
|
||||
TextView(ctx).apply {
|
||||
gravity = Gravity.CENTER
|
||||
textSize = 13f
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
setPadding(24, 12, 24, 12)
|
||||
visibility = if (trip.offerType.isEmpty()) View.GONE else View.VISIBLE
|
||||
applyOfferBadgeStyle(this, trip.offerType == "exclusive")
|
||||
layoutParams =
|
||||
LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
.apply { bottomMargin = 24 }
|
||||
}
|
||||
detailsContainer.addView(offerBadge)
|
||||
|
||||
// ── 4. شريط الوقت ──
|
||||
val countdownLabel =
|
||||
TextView(ctx).apply {
|
||||
@@ -621,7 +649,12 @@ class TripOverlayService : Service() {
|
||||
buttonsRow.addView(acceptBtn)
|
||||
detailsContainer.addView(buttonsRow)
|
||||
|
||||
card.tag = mapOf("countdownLabel" to countdownLabel, "progressBar" to progressBar)
|
||||
card.tag =
|
||||
mapOf(
|
||||
"countdownLabel" to countdownLabel,
|
||||
"progressBar" to progressBar,
|
||||
"offerBadge" to offerBadge
|
||||
)
|
||||
return card
|
||||
}
|
||||
// 🔴 دالة احترافية لقراءة المفتاح المحقون من Gradle 🔴
|
||||
@@ -763,6 +796,52 @@ class TripOverlayService : Service() {
|
||||
// ==========================================================
|
||||
// 🔴 التحكم والمؤقت 🔴
|
||||
// ==========================================================
|
||||
/**
|
||||
* تنسيق شارة نوع الطلب. الحصري بالكهرماني والعام بالرمادي — الفرق
|
||||
* اللوني وحده يكفي ليعرف السائق حالته بنظرة بلا قراءة.
|
||||
*/
|
||||
private fun applyOfferBadgeStyle(view: TextView, exclusive: Boolean) {
|
||||
val color = if (exclusive) "#B8860B" else "#607D8B"
|
||||
val bg = if (exclusive) "#FFF6DC" else "#ECEFF1"
|
||||
view.text = if (exclusive) "⭐ طلب خاص لك" else "🌐 طلب عام"
|
||||
view.setTextColor(Color.parseColor(color))
|
||||
view.background =
|
||||
android.graphics.drawable.GradientDrawable().apply {
|
||||
setColor(Color.parseColor(bg))
|
||||
cornerRadius = 40f
|
||||
setStroke(2, Color.parseColor(color))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* مؤقّت انتهاء الحصرية — منفصل تماماً عن مؤقّت إغلاق النافذة.
|
||||
*
|
||||
* بانقضاء ثواني السبق تصبح الرحلة معروضة للجميع، فتنقلب الشارة إلى
|
||||
* "طلب عام". النافذة **لا تُغلق**: السائق يظل قادراً على القبول، لكنه
|
||||
* صار يتنافس. الخلط بين المؤقّتين كان سيغلق النافذة بعد خمس ثوانٍ
|
||||
* ويحرمه الرحلة كلياً.
|
||||
*/
|
||||
private fun startExclusivityCountdown(seconds: Int) {
|
||||
exclusivityTimer?.cancel()
|
||||
if (seconds <= 0) return
|
||||
|
||||
exclusivityTimer =
|
||||
object : CountDownTimer(seconds * 1000L, 1000L) {
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
val left = (millisUntilFinished / 1000).toInt()
|
||||
val tagMap = overlayView?.tag as? Map<*, *> ?: return
|
||||
val badge = tagMap["offerBadge"] as? TextView ?: return
|
||||
badge.text = "⭐ طلب خاص لك — $left ث"
|
||||
}
|
||||
override fun onFinish() {
|
||||
val tagMap = overlayView?.tag as? Map<*, *> ?: return
|
||||
val badge = tagMap["offerBadge"] as? TextView ?: return
|
||||
applyOfferBadgeStyle(badge, false)
|
||||
}
|
||||
}
|
||||
.start()
|
||||
}
|
||||
|
||||
private fun startCountdown(totalSeconds: Int) {
|
||||
countDownTimer?.cancel()
|
||||
countDownTimer =
|
||||
@@ -796,6 +875,7 @@ class TripOverlayService : Service() {
|
||||
private fun dismissOverlay(reason: String) {
|
||||
stopSound() // 🔴
|
||||
countDownTimer?.cancel()
|
||||
exclusivityTimer?.cancel()
|
||||
if (reason == "rejected" || reason == "timeout") {
|
||||
// النتيجة تذهب لقناة النوع المعروض — لا نخلط عرض توصيل بعرض رحلة
|
||||
if (isFoodOverlay) {
|
||||
@@ -843,7 +923,9 @@ class TripOverlayService : Service() {
|
||||
estimatedFare = obj.getDouble("estimatedFare"),
|
||||
estimatedMinutes = obj.getInt("estimatedMinutes"),
|
||||
pickupLat = obj.getDouble("pickupLat"),
|
||||
pickupLng = obj.getDouble("pickupLng")
|
||||
pickupLng = obj.getDouble("pickupLng"),
|
||||
offerType = obj.optString("offerType", ""),
|
||||
offerExpiresIn = obj.optInt("offerExpiresIn", 0)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
@@ -859,6 +941,9 @@ class TripOverlayService : Service() {
|
||||
val estimatedFare: Double,
|
||||
val estimatedMinutes: Int,
|
||||
val pickupLat: Double,
|
||||
val pickupLng: Double
|
||||
val pickupLng: Double,
|
||||
// "exclusive" خلال السبق الحصري، "public" بعده، وفارغ في البثّ الحر
|
||||
val offerType: String = "",
|
||||
val offerExpiresIn: Int = 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ class TripData {
|
||||
final double pickupLng;
|
||||
final String? passengerAvatarUrl;
|
||||
|
||||
/// نوع الطلب: 'exclusive' خلال ثواني السبق الحصري، 'public' بعده،
|
||||
/// و null في وضع البثّ الحر (عندها لا تظهر أي شارة).
|
||||
final String? offerType;
|
||||
|
||||
/// ثواني السبق المتبقية. بعد انقضائها تقلب النافذة الشارة إلى "طلب عام"
|
||||
/// من نفسها — النافذة لا تُغلق، فالسائق يظل قادراً على القبول.
|
||||
final int offerExpiresIn;
|
||||
|
||||
TripData({
|
||||
required this.tripId,
|
||||
required this.passengerName,
|
||||
@@ -28,6 +36,8 @@ class TripData {
|
||||
required this.pickupLat,
|
||||
required this.pickupLng,
|
||||
this.passengerAvatarUrl,
|
||||
this.offerType,
|
||||
this.offerExpiresIn = 0,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
@@ -41,6 +51,8 @@ class TripData {
|
||||
'pickupLat': pickupLat,
|
||||
'pickupLng': pickupLng,
|
||||
'passengerAvatarUrl': passengerAvatarUrl ?? '',
|
||||
'offerType': offerType ?? '',
|
||||
'offerExpiresIn': offerExpiresIn,
|
||||
};
|
||||
|
||||
factory TripData.fromMap(Map<String, dynamic> map) => TripData(
|
||||
@@ -51,6 +63,10 @@ class TripData {
|
||||
distanceKm: (map['distanceKm'] ?? 0.0).toDouble(),
|
||||
estimatedFare: (map['estimatedFare'] ?? 0.0).toDouble(),
|
||||
estimatedMinutes: map['estimatedMinutes'] ?? 0,
|
||||
offerType: (map['offerType'] ?? '').toString().isEmpty
|
||||
? null
|
||||
: map['offerType'].toString(),
|
||||
offerExpiresIn: int.tryParse('${map['offerExpiresIn'] ?? 0}') ?? 0,
|
||||
pickupLat: (map['pickupLat'] ?? 0.0).toDouble(),
|
||||
pickupLng: (map['pickupLng'] ?? 0.0).toDouble(),
|
||||
passengerAvatarUrl: map['passengerAvatarUrl'],
|
||||
|
||||
Reference in New Issue
Block a user