325 lines
16 KiB
PHP
325 lines
16 KiB
PHP
<?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");
|
||
}
|
||
|
||
// الأقواس ضرورية: الفاصلة العربية `،` بايتاتها ≥ 0x80، وPHP يعدّها جزءاً
|
||
// صالحاً من اسم المتغيّر — فبدونها يُقرأ "$dispatched،" متغيّراً آخر فارغاً.
|
||
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());
|
||
}
|
||
}
|