Update: 2026-08-07 05:56:50
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_scheduled_rides.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* يحوّل الحجوزات المستحقة إلى رحلات فعلية، ويغذّي توقّع الطلب.
|
||||
*
|
||||
* ── لماذا لا تُنشأ الرحلة وقت الحجز ──
|
||||
* رحلة تُنشأ الآن لموعد بعد ست ساعات ستدور في السوق ست ساعات: تُربك
|
||||
* الإسناد، وتشوّه الخريطة الحرارية، وتُحتسب في كثافة الطلب اللحظية.
|
||||
* الحجز يبقى نيّة حتى يقترب موعده.
|
||||
*
|
||||
* ── وظيفتان ──
|
||||
* ١. تذكير الراكب قبل موعده (ساعة، ثم ربع ساعة)
|
||||
* ٢. إطلاق المستحق: الحجوزات التي بلغ موعدها ناقص هامش البحث
|
||||
* ٣. نشر التوقّع: عدد الحجوزات القادمة لكل خلية جغرافية في Redis،
|
||||
* ليقرأها cron_predictive_demand والخريطة الحرارية
|
||||
*
|
||||
* الثانية هي القيمة الحقيقية: حجز مؤكد لبعد ست ساعات معلومة يقينية،
|
||||
* أدق من أي تنبؤ إحصائي، وتسمح بتوجيه السائقين **قبل** الذروة.
|
||||
*
|
||||
* جدولة مقترحة (crontab): **كل دقيقتين**، لا كل دقيقة.
|
||||
*
|
||||
* الاستعلامات كلها مفهرسة على (status, scheduled_at) وتكلفتها مهملة،
|
||||
* لكن كل تشغيل يعني إنشاء عملية PHP جديدة داخل الحاوية — وهذا هو الحمل
|
||||
* الحقيقي لا الاستعلام. وهوامش الإطلاق ١٥-٤٠ دقيقة، فدقيقة تأخير في
|
||||
* الاستحقاق لا أثر لها إطلاقاً.
|
||||
*
|
||||
* *\/2 * * * * (كل دقيقتين)
|
||||
* docker compose exec -T php php /var/www/backend/bot/cron_scheduled_rides.php
|
||||
*
|
||||
* ⚠️ يجب أن يعمل داخل حاوية php — Redis لا ينشر منافذ.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
// حجز فات موعده بهذا القدر بلا إطلاق يُعتبر منتهياً: الراكب لم يعد
|
||||
// ينتظره، وإطلاقه الآن يرسل له سائقاً لموعد مضى.
|
||||
const SCHEDULE_EXPIRE_GRACE_MINUTES = 30;
|
||||
|
||||
// دقة خلية التوقّع — نفس شبكة cron_predictive_demand (0.01° ≈ 1.1 كم).
|
||||
const SCHEDULE_GRID = 0.01;
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, '[scheduled] DB unavailable: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١) انتهاء صلاحية الحجوزات المهملة
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$expired = 0;
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
UPDATE scheduled_rides
|
||||
SET status = 'expired'
|
||||
WHERE status = 'scheduled'
|
||||
AND scheduled_at < NOW() - INTERVAL " . SCHEDULE_EXPIRE_GRACE_MINUTES . " MINUTE
|
||||
");
|
||||
$st->execute();
|
||||
$expired = $st->rowCount();
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[scheduled] تعذّر تعليم المنتهية: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١.٥) تذكير الراكب
|
||||
//
|
||||
// تذكيران: قبل ساعة ليرتّب أمره، وقبل ربع ساعة ليستعد للنزول.
|
||||
// الختم في العمود يمنع التكرار — بدونه يصل التذكير على كل مرور للكرون.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$reminded = 0;
|
||||
foreach ([[60, 'reminded_60_at', 'رحلتك المحجوزة بعد ساعة'],
|
||||
[15, 'reminded_15_at', 'رحلتك المحجوزة بعد ربع ساعة']] as [$mins, $col, $title]) {
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
SELECT id, passenger_id, scheduled_at, start_name, end_name
|
||||
FROM scheduled_rides
|
||||
WHERE status = 'scheduled'
|
||||
AND `$col` IS NULL
|
||||
AND scheduled_at BETWEEN NOW() AND NOW() + INTERVAL $mins MINUTE
|
||||
LIMIT 100
|
||||
");
|
||||
$st->execute();
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// الختم أولاً ثم الإرسال: لو انقطع التنفيذ بينهما، أسوأ نتيجة
|
||||
// تذكير لم يصل — لا تذكير يصل مئة مرة.
|
||||
$con->prepare("UPDATE scheduled_rides SET `$col` = NOW() WHERE id = ?")
|
||||
->execute([$row['id']]);
|
||||
|
||||
scheduleNotifyPassenger(
|
||||
$con,
|
||||
$row['passenger_id'],
|
||||
$title,
|
||||
'من ' . ($row['start_name'] ?: 'موقعك') . ' في '
|
||||
. date('H:i', strtotime($row['scheduled_at'])),
|
||||
['category' => 'scheduled_reminder', 'scheduled_id' => (string) $row['id']]
|
||||
);
|
||||
$reminded++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "[scheduled] تعذّر تذكير $mins د: " . $e->getMessage() . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٢) الحجوزات المستحقة للإطلاق
|
||||
//
|
||||
// المستحق: بلغ الوقت موعدَه ناقص هامشه. الهامش مشتقّ من المسافة وقت
|
||||
// الحجز (رحلة مطار تحتاج وقتاً أطول لإيجاد سائق وللوصول).
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$dispatched = 0;
|
||||
$failed = 0;
|
||||
|
||||
try {
|
||||
$due = $con->prepare("
|
||||
SELECT * FROM scheduled_rides
|
||||
WHERE status = 'scheduled'
|
||||
AND NOW() >= scheduled_at - INTERVAL lead_minutes MINUTE
|
||||
AND scheduled_at >= NOW() - INTERVAL " . SCHEDULE_EXPIRE_GRACE_MINUTES . " MINUTE
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT 100
|
||||
");
|
||||
$due->execute();
|
||||
$bookings = $due->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($bookings as $b) {
|
||||
// قفل تفاؤلي: مرور ثانٍ للكرون (أو نسخة ثانية منه) لا يطلق نفس
|
||||
// الحجز مرتين. الرحلة المزدوجة تعني سائقين وراكباً واحداً.
|
||||
$lock = $con->prepare("
|
||||
UPDATE scheduled_rides SET status = 'dispatching'
|
||||
WHERE id = ? AND status = 'scheduled'
|
||||
");
|
||||
$lock->execute([$b['id']]);
|
||||
if ($lock->rowCount() === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rideId = scheduleCreateRide($b);
|
||||
|
||||
if ($rideId) {
|
||||
$con->prepare("
|
||||
UPDATE scheduled_rides SET status = 'dispatched', ride_id = ?
|
||||
WHERE id = ?
|
||||
")->execute([$rideId, $b['id']]);
|
||||
$dispatched++;
|
||||
error_log("[scheduled] حجز #{$b['id']} → رحلة #$rideId");
|
||||
} else {
|
||||
// نعيده 'scheduled' لا 'failed': المرور القادم يحاول ثانيةً،
|
||||
// وفشل شبكي عابر لا يجوز أن يحرم الراكب رحلته المحجوزة.
|
||||
$con->prepare("
|
||||
UPDATE scheduled_rides SET status = 'scheduled' WHERE id = ?
|
||||
")->execute([$b['id']]);
|
||||
$failed++;
|
||||
error_log("[scheduled] تعذّر إنشاء رحلة للحجز #{$b['id']} — ستُعاد المحاولة");
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[scheduled] فشل الإطلاق: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٣) نشر التوقّع المؤكد
|
||||
//
|
||||
// هذه هي القيمة التي تتجاوز راحة الراكب: طلب معروف مسبقاً بمكانه
|
||||
// ووقته. يُنشر في Redis ليقرأه التنبؤ والخريطة الحرارية.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$cells = 0;
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
// نافذة ٦ ساعات: أبعد من ذلك لا يفيد توجيه السائقين اليوم.
|
||||
$st = $con->query("
|
||||
SELECT start_location, scheduled_at
|
||||
FROM scheduled_rides
|
||||
WHERE status = 'scheduled'
|
||||
AND scheduled_at BETWEEN NOW() AND NOW() + INTERVAL 6 HOUR
|
||||
");
|
||||
|
||||
$buckets = [];
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
[$lat, $lng] = array_pad(
|
||||
array_map('trim', explode(',', (string) $row['start_location'], 2)), 2, null
|
||||
);
|
||||
if (!is_numeric($lat) || !is_numeric($lng)) continue;
|
||||
|
||||
// المفتاح: خلية جغرافية + الساعة المتوقعة. التجميع بالساعة
|
||||
// يكفي لقرار "أين يجب أن يكون السائقون الساعة السابعة".
|
||||
$cell = round((float) $lat / SCHEDULE_GRID) * SCHEDULE_GRID
|
||||
. ',' . round((float) $lng / SCHEDULE_GRID) * SCHEDULE_GRID;
|
||||
$hour = date('Y-m-d H:00', strtotime($row['scheduled_at']));
|
||||
|
||||
$buckets["$cell|$hour"] = ($buckets["$cell|$hour"] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$key = 'demand:scheduled';
|
||||
$redisLocation->del($key);
|
||||
if (!empty($buckets)) {
|
||||
$redisLocation->hMSet($key, $buckets);
|
||||
// عمر ساعتين: الكرون يعيد بناءه كل دقيقة، والانتهاء يمنع
|
||||
// بقاء توقّع قديم لو توقّف الكرون.
|
||||
$redisLocation->expire($key, 7200);
|
||||
}
|
||||
$cells = count($buckets);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, '[scheduled] تعذّر نشر التوقّع: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[scheduled] أُطلق $dispatched، تعذّر $failed، انتهى $expired،"
|
||||
. " ذُكِّر $reminded، وخلايا التوقّع $cells\n";
|
||||
|
||||
|
||||
/**
|
||||
* ينشئ الرحلة الفعلية من الحجز عبر add_ride.php.
|
||||
*
|
||||
* نستدعي النقطة ذاتها لا نكرّر منطقها: add_ride يسعّر، ويبني الحمولة،
|
||||
* ويشغّل الإسناد (بالدفعات أو البثّ الحر)، ويكتب للقاعدتين. تكرار ذلك
|
||||
* هنا كان سينتج نسخة تتباعد عن الأصل مع أول تعديل.
|
||||
*
|
||||
* السعر يُحسب لحظة الإنشاء لا وقت الحجز: سعر ما قبل ست ساعات لا يعرف
|
||||
* الذروة ولا الطقس ولا كثافة السائقين.
|
||||
*
|
||||
* @return string|null رقم الرحلة، أو null عند الفشل.
|
||||
*/
|
||||
function scheduleCreateRide(array $b): ?string
|
||||
{
|
||||
$url = getenv('INTERNAL_API_BASE') ?: 'http://nginx';
|
||||
$url .= '/ride/rides/add_ride.php';
|
||||
|
||||
[$startLat, $startLng] = array_pad(
|
||||
array_map('trim', explode(',', (string) $b['start_location'], 2)), 2, ''
|
||||
);
|
||||
[$endLat, $endLng] = array_pad(
|
||||
array_map('trim', explode(',', (string) $b['end_location'], 2)), 2, ''
|
||||
);
|
||||
|
||||
$fields = [
|
||||
'passenger_id' => $b['passenger_id'],
|
||||
'start_location' => $b['start_location'],
|
||||
'end_location' => $b['end_location'],
|
||||
'start_lat' => $startLat,
|
||||
'start_lng' => $startLng,
|
||||
'end_lat' => $endLat,
|
||||
'end_lng' => $endLng,
|
||||
'start_name' => $b['start_name'] ?? '',
|
||||
'end_name' => $b['end_name'] ?? '',
|
||||
'car_type' => $b['car_type'],
|
||||
'distance' => $b['distance'],
|
||||
'duration' => $b['duration'],
|
||||
// السعر يُتجاهَل: add_ride يسعّر داخلياً لحظة الإنشاء. نمرّره
|
||||
// للتوثيق فقط — تقدير وقت الحجز لا يعرف الذروة ولا الطقس.
|
||||
'price' => $b['estimated_price'],
|
||||
'scheduled_at' => $b['scheduled_at'],
|
||||
// وسم المصدر: add_ride قد يعامل الرحلة المحجوزة بأولوية أو
|
||||
// هامش مختلف لاحقاً، والوسم يجعل ذلك ممكناً بلا تخمين.
|
||||
'source' => 'scheduled',
|
||||
'scheduled_id' => $b['id'],
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($fields),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
|
||||
],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code !== 200 || !$body) {
|
||||
error_log("[scheduled] add_ride رمز=$code رد=" . substr((string) $body, 0, 200));
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($body, true);
|
||||
|
||||
// add_ride يرد بـ printSuccess($insertedId) — الرقم في message.
|
||||
$rideId = $json['message'] ?? $json['data'] ?? null;
|
||||
|
||||
return ($rideId && is_scalar($rideId)) ? (string) $rideId : null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* يرسل إشعاراً للراكب. توكنه مشفَّر في جدول tokens — نفكّه كما يفعل
|
||||
* cancel_ride_by_driver.php بالضبط.
|
||||
*
|
||||
* لا يرمي: فشل التذكير لا يُسقط الكرون ولا يمنع إطلاق الحجوزات.
|
||||
*/
|
||||
function scheduleNotifyPassenger(PDO $con, string $passengerId,
|
||||
string $title, string $body, array $data): void
|
||||
{
|
||||
global $encryptionHelper;
|
||||
|
||||
try {
|
||||
$st = $con->prepare("SELECT token FROM tokens WHERE passengerID = ? ORDER BY id DESC LIMIT 1");
|
||||
$st->execute([$passengerId]);
|
||||
$raw = $st->fetchColumn();
|
||||
if (!$raw) return;
|
||||
|
||||
$token = $raw;
|
||||
if (!empty($encryptionHelper)) {
|
||||
try {
|
||||
$dec = $encryptionHelper->decryptData($raw);
|
||||
if ($dec !== false && $dec !== '') $token = trim($dec);
|
||||
} catch (Throwable $e) { /* نستخدم الخام */ }
|
||||
}
|
||||
|
||||
if (function_exists('sendFCM_Internal')) {
|
||||
sendFCM_Internal($token, $title, $body, $data, 'scheduled_reminder', false);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('[scheduled] تعذّر إرسال التذكير: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user