Files
tripz-llc/backend/bot/cron_scheduled_rides.php
T
2026-08-09 16:56:13 +03:00

325 lines
16 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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());
}
}