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()); } }