Update: 2026-08-07 05:56:50

This commit is contained in:
Hamza-Ayed
2026-08-07 05:56:50 +03:00
parent 7a932580a3
commit 67465df000
14 changed files with 1257 additions and 9 deletions
+322
View File
@@ -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());
}
}