Update codebase
This commit is contained in:
@@ -46,6 +46,64 @@ if (!in_array($status, $allowedStatuses, true)) {
|
||||
error_log("[accept_ride] DriverID=$driverId attempting RideID=$rideId");
|
||||
|
||||
try {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP A0 — تطبيق الإيقاف على الخادم
|
||||
// كان الإيقاف بعد ٣ إلغاءات يُكتب في تخزين تطبيق السائق وحده، فيُفكّ
|
||||
// بمسح بيانات التطبيق أو إعادة تثبيته. هذه هي البوابة الحقيقية: سائق
|
||||
// موقوف لا يستطيع أن يفوز برحلة مهما فعل بتطبيقه.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// الفحص من Redis لا MySQL: هذا المسار يُستدعى على كل محاولة قبول من كل
|
||||
// سائق، والإيقاف حالة نادرة قصيرة العمر. مفتاح بـ TTL يجعل انتهاء
|
||||
// الإيقاف تلقائياً بلا كرون ولا استعلام تنظيف.
|
||||
// MySQL يبقى السجل الدائم للمراجعة الإدارية، ولا يُقرأ هنا.
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$blockedUntil = $redisLocation->get("driver:$driverId:blocked");
|
||||
if ($blockedUntil) {
|
||||
error_log("[accept_ride] DriverID=$driverId موقوف حتى $blockedUntil — رُفض القبول");
|
||||
printFailure("Your account is temporarily suspended until $blockedUntil");
|
||||
exit;
|
||||
}
|
||||
|
||||
// تجميد سلامة — بلا TTL: لا ينتهي بالوقت بل برفع إنسان له.
|
||||
// سائق عليه بلاغ سلامة مفتوح لا يُسند له راكب جديد.
|
||||
$safetyHold = $redisLocation->get("driver:$driverId:safety_hold");
|
||||
if ($safetyHold) {
|
||||
error_log("[accept_ride] DriverID=$driverId مجمّد ببلاغ سلامة #$safetyHold — رُفض القبول");
|
||||
printFailure("Your account is under review. Please contact support.");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $eBlock) {
|
||||
error_log("[accept_ride] تعذّر فحص الإيقاف: " . $eBlock->getMessage());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP A0b — حارس الحصرية في الإسناد بالدفعات
|
||||
//
|
||||
// بدون هذا الفحص تبقى الحصرية شكلية: سائق تلقّى العرض سابقاً (أو صاغ
|
||||
// الطلب يدوياً) يستطيع القبول خارج دوره فيعود السباق كما كان.
|
||||
//
|
||||
// وجود المفتاح هو ما يحدد الوضع: البثّ الحر لا يكتبه أصلاً، فالفحص
|
||||
// يمرّ بلا أثر — لا حاجة لقراءة DISPATCH_MODE هنا.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$currentOffer = $redisLocation->get("ride:$rideId:current_offer");
|
||||
if ($currentOffer !== false && $currentOffer !== null
|
||||
&& (string) $currentOffer !== (string) $driverId) {
|
||||
error_log("[accept_ride] DriverID=$driverId حاول قبول RideID=$rideId"
|
||||
. " والعرض الحالي لـ $currentOffer — رُفض");
|
||||
printFailure("Ride not available");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $eOffer) {
|
||||
// فشل الفحص لا يمنع القبول — القفل التفاؤلي في STEP A يبقى الضامن
|
||||
// ضد الازدواج، وأسوأ نتيجة هنا هي عودة سلوك البثّ الحر لهذه الرحلة.
|
||||
error_log("[accept_ride] تعذّر فحص حصرية العرض: " . $eOffer->getMessage());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP A — القفل على ride DB (المرجع الأساسي)
|
||||
// Optimistic lock: نغير فقط إذا status لا يزال 'waiting' أو 'wait'
|
||||
@@ -70,6 +128,17 @@ try {
|
||||
|
||||
error_log("[accept_ride] ride DB locked. RideID=$rideId → DriverID=$driverId");
|
||||
|
||||
// عدّاد القبول — بسط معدّل القبول الذي يرتّب به محرك الإسناد.
|
||||
// يُعدّ هنا بعد نجاح القفل حصراً: العدّ قبله كان سيحسب للخاسرين في
|
||||
// السباق قبولاً لم يحدث.
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$redisLocation->hIncrBy("driver:score:$driverId", 'accepts', 1);
|
||||
}
|
||||
} catch (Throwable $eScore) {
|
||||
error_log("[accept_ride] تعذّر تحديث عدّاد القبول: " . $eScore->getMessage());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP B — تزامن primary DB (بعد نجاح القفل)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -87,6 +156,53 @@ try {
|
||||
error_log("[accept_ride] primary DB sync WARNING: " . $eSync->getMessage());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP B1 — تثبيت مسافة الاقتراب لحظة القبول (في Redis لا MySQL)
|
||||
//
|
||||
// هذه المسافة أساس تعويض عدم الحضور لاحقاً، ويجب أن تُقاس الآن حصراً:
|
||||
// عند الإلغاء يكون السائق واقفاً عند نقطة الالتقاط فتصير صفراً.
|
||||
//
|
||||
// لكنها قيمة عابرة تُقرأ مرة واحدة عند نهاية الرحلة — فلا تستحق كتابتين
|
||||
// في MySQL على أسخن مسار في النظام. تُحفظ في نفس الهاش الذي يستخدمه
|
||||
// السوكِت لحالة الرحلة (ride:$id:state)، وتُثبَّت في MySQL كتابةً واحدة
|
||||
// مع تحديث الحالة النهائي عند الإلغاء.
|
||||
//
|
||||
// hSet لا يمسح حقول السوكِت الأخرى في نفس الهاش، وTTL الـ24 ساعة الذي
|
||||
// يضبطه السوكِت يغطي هذه الحقول تلقائياً.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
try {
|
||||
$approachKm = 0.0;
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$stmtPickup = $con->prepare("SELECT start_location FROM ride WHERE id = ?");
|
||||
$stmtPickup->execute([$rideId]);
|
||||
[$pickupLat, $pickupLng] = array_pad(
|
||||
array_map('trim', explode(',', (string) $stmtPickup->fetchColumn(), 2)), 2, null
|
||||
);
|
||||
|
||||
if (is_numeric($pickupLat) && is_numeric($pickupLng)) {
|
||||
$loc = $redisLocation->hGetAll("driver:public:$driverId");
|
||||
if (!empty($loc['lat']) && !empty($loc['lng'])) {
|
||||
$approachKm = round(getDistanceBetweenPoints(
|
||||
(float) $loc['lat'], (float) $loc['lng'],
|
||||
(float) $pickupLat, (float) $pickupLng
|
||||
), 3);
|
||||
}
|
||||
}
|
||||
|
||||
if ($approachKm > 0) {
|
||||
$redisLocation->hSet("ride:$rideId:state", 'pickup_approach_km', $approachKm);
|
||||
}
|
||||
}
|
||||
|
||||
if ($approachKm <= 0) {
|
||||
error_log("[accept_ride] تعذّر قياس مسافة الاقتراب RideID=$rideId"
|
||||
. " — التعويض سيحتسب زمن الانتظار وحده");
|
||||
}
|
||||
} catch (Throwable $eApproach) {
|
||||
// قياس التعويض لا يُسقط قبول الرحلة أبداً.
|
||||
error_log("[accept_ride] pickupApproachKm WARNING: " . $eApproach->getMessage());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// STEP C — driver_orders (INSERT أو UPDATE بسطر واحد آمن)
|
||||
// ON DUPLICATE KEY يمنع race condition ثانية على هذا الجدول
|
||||
@@ -275,6 +391,42 @@ try {
|
||||
'passenger_id' => $passengerIdValue ?? '',
|
||||
]);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// الراكب الذي طلب برسالة نصية لا يملك إنترنت: السوكيت لن يصله،
|
||||
// والإشعار الصامت أعلاه يذهب إلى العدم. الرسالة النصية هي قناته
|
||||
// الوحيدة لمعرفة أن سائقاً قَبِل، ومن هو، وكيف يعرف سيارته.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
try {
|
||||
$stmtSms = $con->prepare("
|
||||
SELECT sender_norm FROM sms_ride_requests
|
||||
WHERE ride_id = ? AND status = 'dispatched' LIMIT 1
|
||||
");
|
||||
$stmtSms->execute([$rideId]);
|
||||
$smsSender = $stmtSms->fetchColumn();
|
||||
|
||||
if ($smsSender) {
|
||||
require_once __DIR__ . '/../../sms/helpers.php';
|
||||
|
||||
$plate = trim((string) ($driverInfo['car_plate'] ?? ''));
|
||||
$car = trim(($driverInfo['make'] ?? '') . ' ' . ($driverInfo['model'] ?? ''));
|
||||
$color = trim((string) ($driverInfo['color'] ?? ''));
|
||||
|
||||
$lines = ['سيرو: وجدنا لك سائقاً.'];
|
||||
$lines[] = 'السائق: ' . ($driverInfo['driverName'] ?? '—');
|
||||
if (!empty($driverInfo['phone'])) $lines[] = 'هاتفه: ' . $driverInfo['phone'];
|
||||
if ($car !== '' || $color !== '') $lines[] = 'السيارة: ' . trim("$car $color");
|
||||
if ($plate !== '') $lines[] = 'اللوحة: ' . $plate;
|
||||
$lines[] = 'سيتصل بك عند وصوله.';
|
||||
|
||||
smsEnqueue($con, (string) $smsSender, implode("\n", $lines),
|
||||
'driver_found', (string) $rideId);
|
||||
}
|
||||
} catch (Throwable $eSms) {
|
||||
// لا نُسقط قبول الرحلة من أجل رسالة: السائق قَبِل فعلاً،
|
||||
// والرسالة الفائتة أهون من رحلة تُلغى بخطأ.
|
||||
error_log('[accept_ride] تعذّر تجهيز رسالة SMS للراكب: ' . $eSms->getMessage());
|
||||
}
|
||||
|
||||
error_log("[accept_ride] SUCCESS. RideID=$rideId accepted by DriverID=$driverId");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
+326
-32
@@ -16,22 +16,20 @@ try {
|
||||
// =================================================================================
|
||||
// 🛠️ دالة مساعدة: إرسال الرحلة لسوق السائقين (Marketplace Broadcast)
|
||||
// =================================================================================
|
||||
function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketData = []) {
|
||||
$url = getenv('LOCATION_SOCKET_URL') ?: 'http://socket_driver:2021';
|
||||
if (strpos($url, 'localhost') !== false || strpos($url, '127.0.0.1') !== false) {
|
||||
if (file_exists('/.dockerenv')) {
|
||||
$url = str_replace(['localhost', '127.0.0.1'], 'socket_driver', $url);
|
||||
}
|
||||
}
|
||||
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
|
||||
// ⚠️ هذه الحمولة تُبَثّ لكل سائق قريب (وليس للفائز فقط) — لا نضع فيها أي
|
||||
// بيانات راكب حسّاسة (هاتف/إيميل/FCM token). ما ينقص السائق من تفاصيل
|
||||
// الراكب يجلبه بعد نجاح القبول، والسيرفر يحلّ التوكن بنفسه في acceptRide.php.
|
||||
//
|
||||
// أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر
|
||||
// رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح — أي حقل ناقص
|
||||
// يصل للسيرفر لاحقاً كنص "null".
|
||||
$marketPayload = array_merge([
|
||||
/**
|
||||
* حمولة الرحلة كما يراها السائق قبل القبول.
|
||||
*
|
||||
* مشتركة بين البثّ الحر والإسناد بالدفعات عمداً: أي حقل يظهر في أحدهما
|
||||
* ولا يظهر في الآخر يصل للسيرفر لاحقاً كنص "null".
|
||||
*
|
||||
* ⚠️ لا تضع هنا أي بيانات راكب حسّاسة (هاتف/إيميل/FCM token) — هذه الحمولة
|
||||
* تصل سائقين لن يقودوا الرحلة. التفاصيل تُجلب بعد نجاح القبول.
|
||||
*
|
||||
* أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر
|
||||
* رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح.
|
||||
*/
|
||||
function buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData = []) {
|
||||
return array_merge([
|
||||
'id' => (string)$rideId,
|
||||
'start_lat' => $lat,
|
||||
'start_lng' => $lng,
|
||||
@@ -47,7 +45,60 @@ function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketDa
|
||||
'duration' => $payloadData[15],
|
||||
'passengerRate' => $payloadData[33],
|
||||
'passengerId' => $payloadData[7],
|
||||
// رحلة محجوزة: يقرأها available_rides_page و order_request_page
|
||||
// ونافذة الأوفرلي. غيابها = رحلة لحظية عادية.
|
||||
'is_scheduled' => $payloadData[39] ?? '',
|
||||
'scheduled_at' => $payloadData[40] ?? '',
|
||||
// راكب بلا إنترنت: طلب عبر رسالة نصية. لن يصله شات ولا إشعار،
|
||||
// والاتصال الهاتفي هو وسيلة التواصل الوحيدة معه.
|
||||
'is_sms' => $payloadData[41] ?? '',
|
||||
], $extraMarketData);
|
||||
}
|
||||
|
||||
/**
|
||||
* يوقظ العرض المتسلسل في السوكِت بعد أن بنى dispatchStartBatched الطابور.
|
||||
* السوكِت هو صاحب المؤقّت لأنه العملية الوحيدة طويلة العمر (Workerman
|
||||
* Timer) — كرون كل دقيقة لا يصلح لمهلة عشر ثوانٍ.
|
||||
*/
|
||||
function notifySocketStartBatchedDispatch($rideId) {
|
||||
$url = getenv('LOCATION_SOCKET_URL') ?: 'http://socket_driver:2021';
|
||||
if (file_exists('/.dockerenv')) {
|
||||
$url = str_replace(['localhost', '127.0.0.1'], 'socket_driver', $url);
|
||||
}
|
||||
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
|
||||
'action' => 'start_batched_dispatch',
|
||||
'ride_id' => $rideId,
|
||||
]));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT_MS, 2000);
|
||||
if ($INTERNAL_KEY) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ["x-internal-key: $INTERNAL_KEY"]);
|
||||
}
|
||||
curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketData = []) {
|
||||
$url = getenv('LOCATION_SOCKET_URL') ?: 'http://socket_driver:2021';
|
||||
if (strpos($url, 'localhost') !== false || strpos($url, '127.0.0.1') !== false) {
|
||||
if (file_exists('/.dockerenv')) {
|
||||
$url = str_replace(['localhost', '127.0.0.1'], 'socket_driver', $url);
|
||||
}
|
||||
}
|
||||
$INTERNAL_KEY = function_exists('getInternalSocketKey') ? getInternalSocketKey() : '';
|
||||
// ⚠️ هذه الحمولة تُبَثّ لكل سائق قريب (وليس للفائز فقط) — لا نضع فيها أي
|
||||
// بيانات راكب حسّاسة (هاتف/إيميل/FCM token). ما ينقص السائق من تفاصيل
|
||||
// الراكب يجلبه بعد نجاح القبول، والسيرفر يحلّ التوكن بنفسه في acceptRide.php.
|
||||
//
|
||||
// أسماء الحقول تطابق getRideWaiting.php لأن available_rides_page.dart يحشر
|
||||
// رحلات الـ socket في نفس القائمة ويقرأ منها بنفس المفاتيح — أي حقل ناقص
|
||||
// يصل للسيرفر لاحقاً كنص "null".
|
||||
$marketPayload = buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData);
|
||||
|
||||
$postData = [
|
||||
'action' => 'market_new_ride',
|
||||
@@ -69,13 +120,155 @@ function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketDa
|
||||
error_log("[add_ride] Request started. passenger_id=" . ($_POST['passenger_id'] ?? '?'));
|
||||
|
||||
// ── 1. Input ───────────────────────────────────────────────────
|
||||
// وسم الحجز المسبق: يمرّره bot/cron_scheduled_rides.php حين يحوّل حجزاً
|
||||
// لرحلة فعلية. الرحلة العادية تتركه فارغاً فلا يظهر شيء في التطبيقات.
|
||||
$isScheduledRide = filterRequest("source") === 'scheduled';
|
||||
$scheduledAtLabel = filterRequest("scheduled_at") ?: '';
|
||||
|
||||
// وسم الطلب بالرسائل النصية: يمرّره sms/ride_request.php لراكب لا
|
||||
// يملك إنترنت. تطبيق السائق يقرأ الوسم ليعرف أن التواصل بالاتصال لا
|
||||
// بالمحادثة — الراكب لن يرى أي إشعار في التطبيق.
|
||||
$isSmsRide = filterRequest("source") === 'sms';
|
||||
|
||||
$start_location = filterRequest("start_location");
|
||||
$end_location = filterRequest("end_location");
|
||||
$price = filterRequest("price");
|
||||
$price_token = filterRequest("price_token");
|
||||
|
||||
// Force passenger_id from JWT — never trust user-supplied passenger_id
|
||||
$passenger_id = $user_id;
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 🔐 مسار S2S للحجز المسبق — بوّابة مزدوجة لا مفتاح وحده
|
||||
//
|
||||
// bot/cron_scheduled_rides.php لا يملك JWT راكب ولا رمز سعر، فلا يمكنه
|
||||
// المرور من الحراسة العادية. لكن فتح استثناء بمفتاح S2S وحده يعني أن
|
||||
// تسريب المفتاح = إنشاء رحلات باسم أي راكب.
|
||||
//
|
||||
// لذلك شرطان معاً:
|
||||
// ١. مفتاح S2S صحيح
|
||||
// ٢. الحجز موجود فعلاً في scheduled_rides بحالة 'dispatching'، ويخصّ
|
||||
// هذا الراكب بالذات، وبنفس نقطة الانطلاق
|
||||
//
|
||||
// الشرط الثاني هو الحماية الحقيقية: لا رحلة تُنشأ إلا مقابل حجز أنشأه
|
||||
// الراكب بنفسه عبر المسار الموثَّق، والكرون قفله للتوّ. حامل المفتاح
|
||||
// وحده لا يستطيع اختلاق حجز.
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
$isInternalScheduled = false;
|
||||
|
||||
// يُملأ أحدهما فقط حسب المصدر. التصريح بهما هنا يمنع تحذير «متغيّر
|
||||
// غير معرّف» في كتلة التسعير الداخلي التي تقرأ الاثنين.
|
||||
$booking = null;
|
||||
$smsReq = null;
|
||||
|
||||
if ($isScheduledRide) {
|
||||
$providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? '';
|
||||
$expectedKey = getenv('S2S_SHARED_KEY') ?: '';
|
||||
$scheduledId = (int) (filterRequest('scheduled_id', 'int') ?: 0);
|
||||
$claimedPassenger = filterRequest('passenger_id');
|
||||
|
||||
if (empty($expectedKey) || !hash_equals($expectedKey, (string) $providedKey)) {
|
||||
error_log('[add_ride] SECURITY: مسار الحجز بمفتاح S2S غير صالح');
|
||||
printFailure('Unauthorized');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($scheduledId <= 0 || empty($claimedPassenger)) {
|
||||
printFailure('Missing scheduled_id or passenger_id');
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmtBooking = $con->prepare("
|
||||
SELECT passenger_id, start_location, end_location, car_type,
|
||||
distance, duration
|
||||
FROM scheduled_rides
|
||||
WHERE id = ? AND status = 'dispatching' LIMIT 1
|
||||
");
|
||||
$stmtBooking->execute([$scheduledId]);
|
||||
$booking = $stmtBooking->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $eB) {
|
||||
error_log('[add_ride] تعذّرت قراءة الحجز: ' . $eB->getMessage());
|
||||
printFailure('Server error');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$booking) {
|
||||
error_log("[add_ride] SECURITY: حجز #$scheduledId غير موجود أو ليس قيد الإطلاق");
|
||||
printFailure('Invalid booking');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ((string) $booking['passenger_id'] !== (string) $claimedPassenger
|
||||
|| !coordsMatch($booking['start_location'], $start_location)) {
|
||||
error_log("[add_ride] SECURITY: حجز #$scheduledId لا يطابق الراكب أو النقطة");
|
||||
printFailure('Booking mismatch');
|
||||
exit;
|
||||
}
|
||||
|
||||
// من هنا نثق ببيانات **الحجز المخزَّن** لا بما وصل في الطلب.
|
||||
$isInternalScheduled = true;
|
||||
$passenger_id = $booking['passenger_id'];
|
||||
$end_location = $booking['end_location'];
|
||||
$carType = $booking['car_type'];
|
||||
$distance = $booking['distance'];
|
||||
} elseif ($isSmsRide) {
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// نفس البوّابة المزدوجة بالضبط، على جدول sms_ride_requests.
|
||||
//
|
||||
// الفرق عن الحجز المسبق أن مصدر الطلب هنا رسالة نصية من رقم لا
|
||||
// يحمل JWT إطلاقاً — فالشرط الثاني هو كل الحماية: لا رحلة إلا
|
||||
// مقابل صف أنشأه الويبهوك من رسالة فعلية وقفله للتوّ.
|
||||
// ══════════════════════════════════════════════════════════
|
||||
$providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? '';
|
||||
$expectedKey = getenv('S2S_SHARED_KEY') ?: '';
|
||||
$smsRequestId = (int) (filterRequest('sms_request_id', 'int') ?: 0);
|
||||
$claimedPassenger = filterRequest('passenger_id');
|
||||
|
||||
if (empty($expectedKey) || !hash_equals($expectedKey, (string) $providedKey)) {
|
||||
error_log('[add_ride] SECURITY: مسار SMS بمفتاح S2S غير صالح');
|
||||
printFailure('Unauthorized');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($smsRequestId <= 0 || empty($claimedPassenger)) {
|
||||
printFailure('Missing sms_request_id or passenger_id');
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmtSms = $con->prepare("
|
||||
SELECT passenger_id, start_location, end_location, distance, duration
|
||||
FROM sms_ride_requests
|
||||
WHERE id = ? AND status = 'dispatching' LIMIT 1
|
||||
");
|
||||
$stmtSms->execute([$smsRequestId]);
|
||||
$smsReq = $stmtSms->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $eS) {
|
||||
error_log('[add_ride] تعذّرت قراءة طلب SMS: ' . $eS->getMessage());
|
||||
printFailure('Server error');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$smsReq) {
|
||||
error_log("[add_ride] SECURITY: طلب SMS #$smsRequestId غير موجود أو ليس قيد الإطلاق");
|
||||
printFailure('Invalid sms request');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ((string) $smsReq['passenger_id'] !== (string) $claimedPassenger
|
||||
|| !coordsMatch($smsReq['start_location'], $start_location)) {
|
||||
error_log("[add_ride] SECURITY: طلب SMS #$smsRequestId لا يطابق الراكب أو النقطة");
|
||||
printFailure('SMS request mismatch');
|
||||
exit;
|
||||
}
|
||||
|
||||
// نثق بالصف المخزَّن لا بما وصل في الطلب.
|
||||
$isInternalScheduled = true; // نفس الإعفاء من رمز السعر و JWT
|
||||
$passenger_id = $smsReq['passenger_id'];
|
||||
$end_location = $smsReq['end_location'];
|
||||
$distance = $smsReq['distance'];
|
||||
} else {
|
||||
// Force passenger_id from JWT — never trust user-supplied passenger_id
|
||||
$passenger_id = $user_id;
|
||||
}
|
||||
$driver_id = (string)(filterRequest("driver_id") ?: '0');
|
||||
$status = filterRequest("status") ?: 'nothing';
|
||||
$price_for_driver = filterRequest("price_for_driver") ?: ($price ?: '0');
|
||||
@@ -116,34 +309,73 @@ if (empty($passenger_id) || empty($start_location) || empty($end_location) || em
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── التسعير الداخلي لمسار الحجز ─────────────────────────────
|
||||
// السعر لا يأتي من الطلب: الكرون يمرّر تقدير وقت الحجز، وهو قديم بساعات
|
||||
// ولا يعرف الذروة. نحسبه هنا من أسعار الدولة لحظة الإنشاء.
|
||||
if ($isInternalScheduled) {
|
||||
require_once __DIR__ . '/../pricing/pricing_helper.php';
|
||||
|
||||
$kazanRow = [];
|
||||
try {
|
||||
$stmtK = $con->prepare("SELECT * FROM kazan WHERE country = ? LIMIT 1");
|
||||
$stmtK->execute([getenv('GLOBAL_COUNTRY') ?: 'Jordan']);
|
||||
$kazanRow = $stmtK->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
} catch (PDOException $eK) {
|
||||
error_log('[add_ride] تعذّر جلب أسعار الدولة للحجز: ' . $eK->getMessage());
|
||||
}
|
||||
|
||||
if (empty($kazanRow)) {
|
||||
printFailure('Pricing unavailable');
|
||||
exit;
|
||||
}
|
||||
|
||||
// المدّة من الصف المخزَّن أيّاً كان مصدره — الحجز المسبق أو طلب
|
||||
// الرسائل. قراءتها من `$booking` وحده كانت تمرّر صفراً لكل رحلة
|
||||
// قادمة من SMS، فيسقط شقّ الزمن من السعر.
|
||||
$internalDuration = (int) ($booking['duration'] ?? $smsReq['duration'] ?? 0);
|
||||
|
||||
$price = computeInternalRidePrice(
|
||||
$kazanRow, (string) $carType, (float) $distance, $internalDuration
|
||||
);
|
||||
$price_for_driver = $price;
|
||||
$price_for_passenger = $price;
|
||||
|
||||
$internalRef = $isSmsRide ? "طلب SMS #$smsRequestId" : "حجز #$scheduledId";
|
||||
error_log("[add_ride] $internalRef سُعِّر داخلياً: $price");
|
||||
}
|
||||
|
||||
// SECURE PRICE TOKEN VERIFICATION
|
||||
if (empty($price_token)) {
|
||||
if (!$isInternalScheduled && empty($price_token)) {
|
||||
error_log("[add_ride] Security failed — price_token is missing.");
|
||||
printFailure("Secure price token is required");
|
||||
exit;
|
||||
}
|
||||
|
||||
$decrypted = isset($encryptionHelper) ? $encryptionHelper->decryptData($price_token) : false;
|
||||
if (!$decrypted) {
|
||||
$decrypted = (!$isInternalScheduled && isset($encryptionHelper))
|
||||
? $encryptionHelper->decryptData($price_token) : false;
|
||||
if (!$isInternalScheduled && !$decrypted) {
|
||||
error_log("[add_ride] Security failed — failed to decrypt price_token.");
|
||||
printFailure("Invalid or tampered price token");
|
||||
exit;
|
||||
}
|
||||
|
||||
$tokenData = json_decode($decrypted, true);
|
||||
if (!$tokenData || !isset($tokenData['expires']) || $tokenData['expires'] < time()) {
|
||||
$tokenData = $decrypted ? json_decode($decrypted, true) : null;
|
||||
if (!$isInternalScheduled
|
||||
&& (!$tokenData || !isset($tokenData['expires']) || $tokenData['expires'] < time())) {
|
||||
error_log("[add_ride] Security failed — token is expired or invalid JSON.");
|
||||
printFailure("Price token has expired, please request estimation again");
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($tokenData['passenger_id'] != $passenger_id) {
|
||||
if (!$isInternalScheduled && $tokenData['passenger_id'] != $passenger_id) {
|
||||
error_log("[add_ride] Security failed — passenger_id mismatch.");
|
||||
printFailure("Tampered price token (passenger mismatch)");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!coordsMatch($tokenData['start_location'], $start_location) || !coordsMatch($tokenData['end_location'], $end_location)) {
|
||||
if (!$isInternalScheduled
|
||||
&& (!coordsMatch($tokenData['start_location'], $start_location)
|
||||
|| !coordsMatch($tokenData['end_location'], $end_location))) {
|
||||
error_log("[add_ride] Security failed — coordinates mismatch. Token: " . ($tokenData['start_location'] . " / " . $tokenData['end_location']) . " Request: " . ($start_location . " / " . $end_location));
|
||||
printFailure("Tampered price token (route mismatch)");
|
||||
exit;
|
||||
@@ -350,6 +582,17 @@ try {
|
||||
// اللي بتوصلها البيانات كـ List مش كـ Map مسمّى مثل FCM
|
||||
isset($extraDispatchData['driver_earnings_extra']) ? $extraDispatchData['driver_earnings_extra'] : '',
|
||||
isset($extraDispatchData['driver_earnings_currency']) ? $extraDispatchData['driver_earnings_currency'] : '',
|
||||
// 🆕 Index 37/38: وسم العرض الحصري في الإسناد بالدفعات
|
||||
'',
|
||||
'',
|
||||
// 🆕 Index 39/40: رحلة محجوزة مسبقاً — السائق يجب أن يعرف أنها
|
||||
// ليست طلباً لحظياً بل موعد مضبوط، وأن الراكب ينتظره في وقت محدد.
|
||||
$isScheduledRide ? '1' : '',
|
||||
$isScheduledRide ? (string) $scheduledAtLabel : '',
|
||||
// 🆕 Index 41: طلب وصل عبر رسالة نصية — الراكب بلا إنترنت.
|
||||
// السائق يجب أن يعرف أن المحادثة داخل التطبيق لن تصل إليه،
|
||||
// وأن الاتصال الهاتفي هو وسيلة التواصل الوحيدة معه.
|
||||
$isSmsRide ? '1' : '',
|
||||
];
|
||||
|
||||
// Direct dispatch للسائقين القريبين
|
||||
@@ -428,15 +671,66 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($driversData)) {
|
||||
dispatchRideToDrivers($driversData, $insertedId, $payload, $start_name_loc, $encryptionHelper, $extraDispatchData);
|
||||
error_log("[add_ride] Dispatched RideID=$insertedId to " . count($driversData) . " drivers.");
|
||||
} else {
|
||||
error_log("[add_ride] No direct drivers found for RideID=$insertedId — market only.");
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// الإسناد — بالدفعات أو بالبثّ الحر
|
||||
//
|
||||
// batched: عرض حصري متسلسل على مرشحين مرتبين. لا بثّ للسوق، ولا
|
||||
// إشعار جماعي — وإلا لضاع معنى الحصرية.
|
||||
// broadcast (الافتراضي): السلوك القائم حرفياً بلا أي تغيير.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
require_once __DIR__ . '/../dispatch/dispatch_helper.php';
|
||||
|
||||
$batchedStarted = false;
|
||||
if (dispatchIsBatched((string) ($payload[31] ?? ''))) {
|
||||
$batchedStarted = dispatchStartBatched(
|
||||
$redisLocation ?? null,
|
||||
$insertedId,
|
||||
$driversData ?: [],
|
||||
buildMarketPayload($insertedId, $startLat, $startLng, $payload, $extraDispatchData)
|
||||
);
|
||||
|
||||
if ($batchedStarted) {
|
||||
notifySocketStartBatchedDispatch($insertedId);
|
||||
|
||||
// السوكِت يوصل العرض لمن تطبيقه مفتوح فقط. بلا FCM، السائق
|
||||
// الأفضل ترتيباً وتطبيقه مغلق لا يعلم بسبقه إطلاقاً — فيضيع
|
||||
// السبق كله ويذهب للبثّ. نرسل له إشعاراً موازياً.
|
||||
$headStartDriver = dispatchHeadStartDriver($redisLocation ?? null, $insertedId);
|
||||
if ($headStartDriver !== null) {
|
||||
$headDriverRow = null;
|
||||
foreach ($driversData as $d) {
|
||||
if ((string)($d['driver_id'] ?? '') === $headStartDriver) {
|
||||
$headDriverRow = $d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($headDriverRow) {
|
||||
dispatchRideToDrivers(
|
||||
[$headDriverRow], $insertedId, $payload, $start_name_loc,
|
||||
$encryptionHelper, $extraDispatchData,
|
||||
['exclusive', dispatchHeadStartSeconds()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
error_log("[add_ride] RideID=$insertedId أُسند بالدفعات.");
|
||||
} else {
|
||||
error_log("[add_ride] تعذّر الإسناد بالدفعات RideID=$insertedId"
|
||||
. " — السقوط للبثّ الحر.");
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast للـ marketplace دائماً
|
||||
broadcastRideToMarket($insertedId, $startLat, $startLng, $payload, $extraDispatchData);
|
||||
if (!$batchedStarted) {
|
||||
if (!empty($driversData)) {
|
||||
dispatchRideToDrivers($driversData, $insertedId, $payload, $start_name_loc, $encryptionHelper, $extraDispatchData);
|
||||
error_log("[add_ride] Dispatched RideID=$insertedId to " . count($driversData) . " drivers.");
|
||||
} else {
|
||||
error_log("[add_ride] No direct drivers found for RideID=$insertedId — market only.");
|
||||
}
|
||||
|
||||
// Broadcast للـ marketplace دائماً
|
||||
broadcastRideToMarket($insertedId, $startLat, $startLng, $payload, $extraDispatchData);
|
||||
}
|
||||
|
||||
// رد النجاح للتطبيق
|
||||
printSuccess($insertedId);
|
||||
|
||||
@@ -17,6 +17,10 @@ if (!$rideId || !$driverId) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// نصف قطر قبول ختم الوصول. أوسع من فحص الـ100م في التطبيق بهامش يستوعب
|
||||
// ضعف الـGPS بين المباني، وأضيق من أن يسمح بالختم من حي آخر.
|
||||
const ARRIVAL_STAMP_RADIUS_KM = 0.30;
|
||||
|
||||
try {
|
||||
// 1. تحديث الحالة في السيرفر البعيد (Remote DB - con_ride)
|
||||
$stmtRemote = $con_ride->prepare("UPDATE ride SET status = 'arrived', updated_at = NOW() WHERE id = ? AND driver_id = ? AND status = 'Apply'");
|
||||
@@ -28,6 +32,65 @@ try {
|
||||
$stmtLocal->execute([$rideId, $driverId]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 2.1 ختم لحظة الوصول — أساس تعويض عدم حضور الراكب
|
||||
//
|
||||
// فحص المسافة في التطبيق (100م) على العميل، فيمكن تجاوزه بموقع مزيَّف
|
||||
// ثم انتظار خمس دقائق وقبض تعويض بلا وجود حقيقي. لذلك يتحقق الخادم
|
||||
// هنا بنفسه من موقع السائق في Redis قبل أن يختم الوقت.
|
||||
//
|
||||
// إن تعذّر التحقق (Redis غير متاح أو الموقع مفقود) لا نمنع الوصول —
|
||||
// الحالة تُحدَّث والراكب يُشعَر كالمعتاد — لكن لا نختم وقتاً يترتب عليه
|
||||
// مال لا نستطيع إثباته. النتيجة: لا تعويض لهذه الرحلة، وسجلّ واضح.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// الختم في Redis لا MySQL — قيمة عابرة تُقرأ مرة واحدة عند الإلغاء،
|
||||
// وتُثبَّت في MySQL كتابةً واحدة هناك. hSetNx يجعله ذرّياً: نداء ثانٍ
|
||||
// لا يعيد ضبط عدّاد الانتظار.
|
||||
try {
|
||||
$stateKey = "ride:$rideId:state";
|
||||
|
||||
if (isset($redisLocation) && $redisLocation
|
||||
&& !$redisLocation->hExists($stateKey, 'driver_arrived_at')) {
|
||||
|
||||
$stmtPickup = $con->prepare("SELECT start_location FROM ride WHERE id = ? AND driver_id = ?");
|
||||
$stmtPickup->execute([$rideId, $driverId]);
|
||||
[$pLat, $pLng] = array_pad(
|
||||
array_map('trim', explode(',', (string) $stmtPickup->fetchColumn(), 2)), 2, null
|
||||
);
|
||||
|
||||
$canStamp = false;
|
||||
if (is_numeric($pLat) && is_numeric($pLng)) {
|
||||
$loc = $redisLocation->hGetAll("driver:public:$driverId");
|
||||
if (!empty($loc['lat']) && !empty($loc['lng'])) {
|
||||
$gap = getDistanceBetweenPoints(
|
||||
(float) $loc['lat'], (float) $loc['lng'],
|
||||
(float) $pLat, (float) $pLng
|
||||
);
|
||||
$canStamp = ($gap <= ARRIVAL_STAMP_RADIUS_KM);
|
||||
if (!$canStamp) {
|
||||
error_log("[arrive_ride] ختم الوصول مرفوض — السائق $driverId"
|
||||
. " يبعد " . round($gap, 3) . "كم عن نقطة الالتقاط (ride=$rideId)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($canStamp) {
|
||||
$redisLocation->hSetNx($stateKey, 'driver_arrived_at', time());
|
||||
// تأمين TTL: لو وصل هذا الطلب قبل أول update_ride_state من
|
||||
// السوكِت، الهاش يكون بلا انتهاء صلاحية.
|
||||
if ($redisLocation->ttl($stateKey) < 0) {
|
||||
$redisLocation->expire($stateKey, 86400);
|
||||
}
|
||||
} else {
|
||||
error_log("[arrive_ride] تعذّر إثبات الوصول — لا تعويض لهذه"
|
||||
. " الرحلة (ride=$rideId driver=$driverId)");
|
||||
}
|
||||
}
|
||||
} catch (Throwable $eStamp) {
|
||||
// فشل الختم لا يمنع إشعار الراكب بالوصول.
|
||||
error_log("[arrive_ride] تعذّر ختم وقت الوصول: " . $eStamp->getMessage());
|
||||
}
|
||||
|
||||
// 3. جلب بيانات الراكب للإرسال
|
||||
// نستخدم con_ride لضمان الدقة
|
||||
$stmtPas = $con_ride->prepare("SELECT passenger_id FROM ride WHERE id = ?");
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// cancel_fee_helper.php — رسم إلغاء الراكب بعد قبول السائق
|
||||
//
|
||||
// المبدأ نفسه المطبَّق في تعويض عدم الحضور: من ضيّع وقت الآخر يعوّضه،
|
||||
// والمال يذهب للسائق لا للشركة. الفرق الوحيد أن الزمن هنا يُحتسب من
|
||||
// لحظة القبول — السائق كان يقود نحو الراكب لا واقفاً ينتظره.
|
||||
//
|
||||
// لا شيء من المبلغ يأتي من العميل. كل المدخلات من الخادم:
|
||||
// • مسافة الاقتراب ← Redis (سجّلها acceptRide.php لحظة القبول)
|
||||
// • زمن الانتظار ← ride.rideTimeStart (يكتبه acceptRide.php)
|
||||
// • الأسعار ← جدول kazan عبر pricing_helper.php
|
||||
// ============================================================
|
||||
|
||||
// حد أقصى للدقائق المحتسبة: سائق قَبِل ونسي الرحلة ساعة لا يتحول
|
||||
// نسيانه إلى فاتورة على الراكب.
|
||||
const PASSENGER_CANCEL_MAX_BILLABLE_MINUTES = 20;
|
||||
|
||||
/**
|
||||
* يحسب رسم الإلغاء، أو صفراً مع سبب الإعفاء.
|
||||
*
|
||||
* @param string $exemptReason يُملأ بسبب الإعفاء إن كان الرسم صفراً (بالمرجع)
|
||||
* @return float المبلغ المستحق للسائق
|
||||
*/
|
||||
function computePassengerCancelFee(
|
||||
PDO $con,
|
||||
$redisLocation,
|
||||
$rideId,
|
||||
array $ride,
|
||||
$driverId,
|
||||
string &$exemptReason,
|
||||
&$currency = null
|
||||
): float {
|
||||
// العملة تُحسم من الدولة دائماً، حتى مع الإعفاء: التطبيق يحتاجها ليعرض
|
||||
// "الإلغاء مجاني" أو "سيُخصم 1.20 دينار" بوحدة صحيحة.
|
||||
$currency = 'SYP';
|
||||
// ── إعفاء ١: لا سائق قَبِل بعد ──────────────────────────
|
||||
// لا أحد تحرّك، فلا أحد يستحق تعويضاً. وهذه أغلب حالات الإلغاء.
|
||||
if (empty($driverId) || (string) $driverId === '0') {
|
||||
$exemptReason = 'no_driver_assigned';
|
||||
$rowCur = getKazanForRide($con, $rideId);
|
||||
$currency = !empty($rowCur['currency'])
|
||||
? (string) $rowCur['currency']
|
||||
: getCurrencyByCountry((string) ($rowCur['country'] ?? 'Syria'));
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// ── سياسة الدولة ────────────────────────────────────────
|
||||
// أسعار دولة هذه الرحلة تحديداً — لا أول صف في الجدول.
|
||||
$kazan = getKazanForRide($con, $rideId, 'Syria', $driverId);
|
||||
|
||||
// العمود currency هو المرجع، وgetCurrencyByCountry احتياط إن كان فارغاً.
|
||||
$currency = !empty($kazan['currency'])
|
||||
? (string) $kazan['currency']
|
||||
: getCurrencyByCountry((string) ($kazan['country'] ?? 'Syria'));
|
||||
|
||||
$cap = (float) ($kazan['passengerCancelCap'] ?? 0);
|
||||
if ($cap <= 0) {
|
||||
// الرسم معطّل لهذه الدولة — الافتراضي حتى تضبطه الإدارة.
|
||||
$exemptReason = 'policy_disabled';
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$freeMinutes = (int) ($kazan['passengerCancelFreeMinutes'] ?? 2);
|
||||
$lateExemptMinutes = (int) ($kazan['driverLateExemptMinutes'] ?? 10);
|
||||
|
||||
// ── الزمن منذ القبول ────────────────────────────────────
|
||||
// acceptRide.php يكتب rideTimeStart = NOW() لحظة فوز السائق بالقفل.
|
||||
$acceptedAt = !empty($ride['rideTimeStart']) ? strtotime($ride['rideTimeStart']) : 0;
|
||||
if ($acceptedAt <= 0) {
|
||||
$exemptReason = 'accept_time_unknown';
|
||||
return 0.0;
|
||||
}
|
||||
$minutesSinceAccept = (int) floor((time() - $acceptedAt) / 60);
|
||||
|
||||
// ── إعفاء ٢: نافذة الإلغاء المجاني ──────────────────────
|
||||
// الراكب يغيّر رأيه أو يخطئ الوجهة — دقيقتان لتصحيح ذلك بلا عقاب.
|
||||
if ($minutesSinceAccept < $freeMinutes) {
|
||||
$exemptReason = 'within_free_window';
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// ── حالة الوصول من Redis ────────────────────────────────
|
||||
$approachKm = 0.0;
|
||||
$arrivedTs = 0;
|
||||
if ($redisLocation) {
|
||||
try {
|
||||
$state = $redisLocation->hMGet("ride:$rideId:state",
|
||||
['pickup_approach_km', 'driver_arrived_at']) ?: [];
|
||||
$approachKm = (float) ($state['pickup_approach_km'] ?? 0);
|
||||
$arrivedTs = (int) ($state['driver_arrived_at'] ?? 0);
|
||||
} catch (Throwable $e) {
|
||||
error_log("[cancel_fee] تعذّرت قراءة حالة الرحلة: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── إعفاء ٣: السائق تأخّر ولم يصل ───────────────────────
|
||||
// أهم الإعفاءات الثلاثة: بدونه نعاقب الراكب على بطء السائق، وهذا
|
||||
// يطرد الركاب أسرع مما يوفّره الرسم.
|
||||
if ($arrivedTs <= 0 && $minutesSinceAccept >= $lateExemptMinutes) {
|
||||
$exemptReason = 'driver_late';
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// ── الحساب ──────────────────────────────────────────────
|
||||
$billableMinutes = min($minutesSinceAccept, PASSENGER_CANCEL_MAX_BILLABLE_MINUTES);
|
||||
$carType = (string) ($ride['carType'] ?? $ride['car_type'] ?? 'Speed');
|
||||
|
||||
$fee = ($approachKm * getPerKmRate($carType, $kazan))
|
||||
+ ($billableMinutes * getPerMinRate($kazan));
|
||||
|
||||
// سقف صلب بعملة الدولة — حزام أمان أخير ضد خطأ في صف kazan.
|
||||
$fee = round(min($fee, $cap, getMoneyHardCap($currency)), 2);
|
||||
|
||||
error_log("[cancel_fee] ride=$rideId رسم إلغاء: {$approachKm}كم"
|
||||
. " + {$billableMinutes}د = $fee $currency (سقف $cap)");
|
||||
|
||||
return max(0.0, $fee);
|
||||
}
|
||||
|
||||
/**
|
||||
* ينفّذ التحويل: خصم من الراكب وإضافة للسائق.
|
||||
*
|
||||
* يُستدعى بعد الـcommit — نداءات S2S شبكية لا تُترك داخل معاملة مفتوحة.
|
||||
* لا يرمي أبداً: فشل التحويل يُسجَّل للتسوية ولا يُبطل إلغاءً تمّ فعلاً.
|
||||
*/
|
||||
function applyCancelFeeTransfer(PDO $con, $passengerId, $driverId, $rideId, float $fee): void
|
||||
{
|
||||
if ($fee <= 0 || empty($passengerId) || empty($driverId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$kazanRow = getKazanForRide($con, $rideId, 'Syria', $driverId);
|
||||
$country = strtolower((string) ($kazanRow['country'] ?? 'jordan'));
|
||||
|
||||
$walletServer = "https://walletintaleq.intaleq.xyz";
|
||||
if ($country === 'jordan') {
|
||||
$walletServer = getenv('WALLET_SERVER_JORDAN') ?: $walletServer;
|
||||
} elseif ($country === 'egypt') {
|
||||
$walletServer = getenv('WALLET_SERVER_EGYPT') ?: $walletServer;
|
||||
} else {
|
||||
$walletServer = getenv('WALLET_SERVER_SYRIA') ?: $walletServer;
|
||||
}
|
||||
|
||||
$s2s = function (string $url, array $fields): int {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($fields),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
|
||||
],
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return (int) $code;
|
||||
};
|
||||
|
||||
// خصم من الراكب (دين سالب)
|
||||
$debtCode = $s2s(
|
||||
"$walletServer/v2/main/ride/passengerWallet/add_s2s_debt.php",
|
||||
['passengerID' => $passengerId, 'amount' => -$fee]
|
||||
);
|
||||
|
||||
// إضافة للسائق — paymentID مشتق من الرحلة فيمنع ازدواج الصرف
|
||||
$rewardCode = $s2s(
|
||||
"$walletServer/v2/main/ride/driverWallet/add_s2s_reward.php",
|
||||
[
|
||||
'driverID' => $driverId,
|
||||
'paymentID' => "pax_cancel_$rideId",
|
||||
'amount' => $fee,
|
||||
'paymentMethod' => 'passenger_cancel_compensation',
|
||||
]
|
||||
);
|
||||
|
||||
if ($debtCode !== 200 || $rewardCode !== 200) {
|
||||
// أي طرف فشل يعني اختلالاً يجب أن يظهر في التسوية، لا أن يمرّ صامتاً.
|
||||
error_log("[cancel_fee] MONEY: رسم $fee للرحلة $rideId —"
|
||||
. " خصم الراكب=$debtCode، رصيد السائق=$rewardCode"
|
||||
. " (راكب=$passengerId سائق=$driverId)");
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,32 @@ try {
|
||||
|
||||
require_once __DIR__ . '/streak_helper.php';
|
||||
|
||||
// نفس أسعار الكيلومتر والدقيقة التي تُحسب بها الرحلة في finish_ride_updates.php
|
||||
require_once __DIR__ . '/../pricing/pricing_helper.php';
|
||||
|
||||
$rideId = filterRequest("ride_id");
|
||||
$driverId = filterRequest("driver_id");
|
||||
$reason = filterRequest("reason");
|
||||
$passengerToken = filterRequest("passenger_token");
|
||||
$penaltyFee = (float) filterRequest("penalty_fee");
|
||||
$passengerToken = filterRequest("passenger_token");
|
||||
|
||||
// هذا المسار ليس تغريماً للسائق — هو تعويض له عن عدم حضور الراكب:
|
||||
// السائق وصل نقطة الالتقاط، انتظر المدة المقررة، ولم يحضر الراكب. فيُعوَّض
|
||||
// عن المسافة التي قطعها نحوه + دقائق الانتظار، ويُخصم المبلغ من الراكب.
|
||||
// عقوبة السائق كثير الإلغاء شيء آخر تماماً: إيقاف مؤقت، لا مال (القسم ٢).
|
||||
//
|
||||
// المبلغ لا يُقرأ من الطلب إطلاقاً. كان سابقاً filterRequest("penalty_fee")
|
||||
// فيُخصم من محفظة الراكب بلا سقف ولا تحقق — أي بيد العميل.
|
||||
if (filterRequest("penalty_fee") !== null) {
|
||||
error_log("[cancel_ride_by_driver] SECURITY: تجاهل penalty_fee من العميل"
|
||||
. " (ride=$rideId driver=$driverId) — التعويض يُحسب على الخادم");
|
||||
}
|
||||
|
||||
// حد أقصى لدقائق الانتظار المحتسبة: سائق نسي الرحلة مفتوحة ساعتين لا
|
||||
// يتحول انتظاره إلى فاتورة على الراكب.
|
||||
const CANCEL_MAX_BILLABLE_WAIT_MINUTES = 20;
|
||||
|
||||
// تثبيت الحالة
|
||||
$statusText = "CancelFromDriverAfterApply";
|
||||
$statusText = "CancelFromDriverAfterApply";
|
||||
|
||||
if (!$rideId || !$driverId) {
|
||||
jsonError("Missing parameters");
|
||||
@@ -27,6 +45,54 @@ if (!$rideId || !$driverId) {
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 0. التحقق من الملكية — الرحلة يجب أن تكون مُسندة لهذا السائق
|
||||
// بدون هذا الفحص كان أي driver_id يقدر يلغي أي رحلة ويغرّم راكبها.
|
||||
// ---------------------------------------------------------
|
||||
// SELECT * عمداً لا قائمة أعمدة: أعمدة التعويض (driverArrivedAt /
|
||||
// pickupApproachKm) قد لا تكون مُرحَّلة بعد، واسم عمود نوع السيارة
|
||||
// نفسه متضارب بين الملفات (carType في الschema، car_type في
|
||||
// finish_ride_updates.php). النجمة تجعل الاستعلام يعمل في كل الحالات
|
||||
// وتترك الغياب يُعالَج كقيمة مفقودة بدل أن يُسقط الإلغاء كله.
|
||||
$stmtOwn = $con->prepare("SELECT * FROM ride WHERE id = ? FOR UPDATE");
|
||||
$stmtOwn->execute([$rideId]);
|
||||
$rideRow = $stmtOwn->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$assignedDriver = is_array($rideRow) ? $rideRow['driver_id'] : false;
|
||||
|
||||
// نوع السيارة تحت أي من الاسمين
|
||||
$rideCarType = (string) ($rideRow['carType'] ?? $rideRow['car_type'] ?? 'Speed');
|
||||
|
||||
// مدخلا التعويض عاشا في Redis طوال الرحلة ولم يلمسا MySQL. نقرأهما
|
||||
// هنا قراءةً واحدة يشترك فيها الحساب (القسم ٤٫١) والتوثيق (القسم ٣).
|
||||
$approachKm = 0.0;
|
||||
$arrivedTs = 0;
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$state = $redisLocation->hMGet("ride:$rideId:state",
|
||||
['pickup_approach_km', 'driver_arrived_at']) ?: [];
|
||||
$approachKm = (float) ($state['pickup_approach_km'] ?? 0);
|
||||
$arrivedTs = (int) ($state['driver_arrived_at'] ?? 0);
|
||||
}
|
||||
} catch (Throwable $eState) {
|
||||
error_log("[cancel_ride_by_driver] تعذّرت قراءة حالة الرحلة من Redis: "
|
||||
. $eState->getMessage());
|
||||
}
|
||||
|
||||
if ($assignedDriver === false) {
|
||||
$con->rollBack();
|
||||
jsonError("Ride not found");
|
||||
exit;
|
||||
}
|
||||
|
||||
if ((string) $assignedDriver !== (string) $driverId) {
|
||||
$con->rollBack();
|
||||
error_log("[cancel_ride_by_driver] SECURITY: رفض إلغاء رحلة غير مُسندة"
|
||||
. " (ride=$rideId caller=$driverId assigned=$assignedDriver)");
|
||||
jsonError("Ride is not assigned to this driver");
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 1. معالجة driver_orders (Insert or Update)
|
||||
// ---------------------------------------------------------
|
||||
@@ -61,20 +127,71 @@ try {
|
||||
if ($cancelCount >= 3) {
|
||||
$isBlocked = true;
|
||||
$blockUntil = date('Y-m-d H:i:s', strtotime('+4 hours'));
|
||||
// يمكنك هنا تحديث حالة السائق في جدول driver إذا لزم الأمر
|
||||
|
||||
// تثبيت الإيقاف على الخادم. كان يُكتب في تخزين تطبيق السائق وحده
|
||||
// (statusDriverLocation='blocked')، فيُفكّ بمسح البيانات أو إعادة
|
||||
// التثبيت. الآن acceptRide.php يرفض القبول ما دام المفتاح حياً.
|
||||
//
|
||||
// Redis هو مسار الفحص (TTL ينهي الإيقاف تلقائياً بلا كرون تنظيف)،
|
||||
// وMySQL سجل دائم للمراجعة الإدارية لا يُقرأ في المسار الحار.
|
||||
// هذه الكتابة تحدث في الإلغاء الثالث فقط — لا على كل رحلة.
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$blockKey = "driver:$driverId:blocked";
|
||||
$remaining = $redisLocation->ttl($blockKey);
|
||||
// لا نُقصّر إيقافاً سارياً أطول بإلغاء جديد.
|
||||
if ($remaining < 4 * 3600) {
|
||||
$redisLocation->setex($blockKey, 4 * 3600, $blockUntil);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $eRedisBlock) {
|
||||
error_log("[cancel_ride_by_driver] تعذّر تثبيت الإيقاف في Redis: "
|
||||
. $eRedisBlock->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
$con->prepare("UPDATE driver
|
||||
SET blockedUntil = GREATEST(COALESCE(blockedUntil, ?), ?)
|
||||
WHERE id = ?")
|
||||
->execute([$blockUntil, $blockUntil, $driverId]);
|
||||
} catch (PDOException $eBlock) {
|
||||
error_log("[cancel_ride_by_driver] تعذّر تسجيل الإيقاف في MySQL (شغّل"
|
||||
. " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eBlock->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 3. تحديث حالة الرحلة في جدول ride
|
||||
// ---------------------------------------------------------
|
||||
$sqlRide = "UPDATE ride SET status = ?, driver_id = 0 WHERE id = ?";
|
||||
|
||||
// التوثيق النهائي: بيانات التعويض التي عاشت في Redis طوال الرحلة
|
||||
// تُثبَّت هنا داخل نفس جملة تحديث الحالة — لا كتابة إضافية.
|
||||
// تُقرأ من Redis قبل هذه النقطة (القسم ٤٫١ يقرأها للحساب)، لذا نقرأها
|
||||
// مرة واحدة هنا ونمرّرها للطرفين.
|
||||
$stampArrivedAt = $arrivedTs > 0 ? date('Y-m-d H:i:s', $arrivedTs) : null;
|
||||
|
||||
$sqlRide = "UPDATE ride SET status = ?, driver_id = 0,
|
||||
pickupApproachKm = ?, driverArrivedAt = ?
|
||||
WHERE id = ?";
|
||||
$sqlRideLegacy = "UPDATE ride SET status = ?, driver_id = 0 WHERE id = ?";
|
||||
$rideParams = [$statusText, $approachKm, $stampArrivedAt, $rideId];
|
||||
|
||||
// Local DB
|
||||
$con->prepare($sqlRide)->execute([$statusText, $rideId]);
|
||||
|
||||
try {
|
||||
$con->prepare($sqlRide)->execute($rideParams);
|
||||
} catch (PDOException $eCols) {
|
||||
// أعمدة التعويض غير مُرحَّلة بعد — الإلغاء نفسه لا يتوقف.
|
||||
error_log("[cancel_ride_by_driver] أعمدة التعويض غير موجودة (شغّل"
|
||||
. " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eCols->getMessage());
|
||||
$con->prepare($sqlRideLegacy)->execute([$statusText, $rideId]);
|
||||
}
|
||||
|
||||
// Remote DB (إن وجد)
|
||||
if (isset($con_ride)) {
|
||||
$con_ride->prepare($sqlRide)->execute([$statusText, $rideId]);
|
||||
try {
|
||||
$con_ride->prepare($sqlRide)->execute($rideParams);
|
||||
} catch (PDOException $eCols) {
|
||||
$con_ride->prepare($sqlRideLegacy)->execute([$statusText, $rideId]);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 تصفير التتابع لأن السائق ألغى الرحلة
|
||||
@@ -99,16 +216,62 @@ try {
|
||||
notifyPassengerOnRideServer($passenger_id, $socketPayload);
|
||||
}
|
||||
|
||||
// 4.1. إضافة غرامة الإلغاء على الراكب (الدين) إذا كانت موجودة
|
||||
if ($penaltyFee > 0) {
|
||||
// إضافة القيمة كدين سالب في المحفظة
|
||||
$negativeDebt = -$penaltyFee;
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 4.1. تعويض السائق عن عدم حضور الراكب
|
||||
// = مسافة الاقتراب × سعر الكيلومتر لنوع السيارة
|
||||
// + دقائق الانتظار × سعر الدقيقة حسب وقت اليوم
|
||||
// كل المدخلات مسجَّلة على الخادم؛ لا شيء منها من العميل.
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// أسعار دولة هذه الرحلة تحديداً. كان "LIMIT 1" يعيد أول صف
|
||||
// (سوريا) فيطبّق 40 SYP/كم على رحلة أردنية سعرها 0.284 JOD/كم.
|
||||
// $driverId يُمرَّر صراحةً: القسم ٣ صفّر ride.driver_id قبل هذه
|
||||
// النقطة، فالبحث عبر الرحلة كان سيرجع بلا دولة ويقع على سوريا.
|
||||
$kazan = getKazanForRide($con, $rideId, 'Syria', $driverId);
|
||||
$country = $kazan['country'] ?? 'Jordan';
|
||||
|
||||
// Resolve country and wallet server
|
||||
$stmtKazan = $con->prepare("SELECT country FROM kazan LIMIT 1");
|
||||
$stmtKazan->execute();
|
||||
$kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: ["country" => "Jordan"];
|
||||
$country = $kazan['country'] ?? 'Jordan';
|
||||
$compensationCap = (float) ($kazan['cancelCompensationCap'] ?? 0);
|
||||
$minWaitMinutes = (int) ($kazan['cancelMinWaitMinutes'] ?? 5);
|
||||
|
||||
// $approachKm و$arrivedTs قُرئا من Redis قراءةً واحدة في القسم صفر.
|
||||
$compensation = 0.0;
|
||||
$waitedMinutes = 0;
|
||||
|
||||
if ($compensationCap <= 0) {
|
||||
// التعويض معطّل لهذه الدولة — الافتراضي حتى تضبطه الإدارة.
|
||||
error_log("[cancel_ride_by_driver] التعويض معطّل (cancelCompensationCap=0)"
|
||||
. " ride=$rideId");
|
||||
} elseif ($arrivedTs <= 0) {
|
||||
// لا ختم وصول ⇒ السائق ألغى قبل أن يصل، أو تعذّر إثبات وصوله.
|
||||
error_log("[cancel_ride_by_driver] إلغاء بلا ختم وصول — بلا تعويض"
|
||||
. " ride=$rideId driver=$driverId");
|
||||
} else {
|
||||
$waitedMinutes = (int) floor((time() - $arrivedTs) / 60);
|
||||
|
||||
if ($waitedMinutes < $minWaitMinutes) {
|
||||
error_log("[cancel_ride_by_driver] انتظار $waitedMinutes د < الحد"
|
||||
. " $minWaitMinutes د — بلا تعويض ride=$rideId");
|
||||
} else {
|
||||
$billableMinutes = min($waitedMinutes, CANCEL_MAX_BILLABLE_WAIT_MINUTES);
|
||||
|
||||
$perKm = getPerKmRate($rideCarType, $kazan);
|
||||
$perMin = getPerMinRate($kazan);
|
||||
|
||||
$compensation = ($approachKm * $perKm) + ($billableMinutes * $perMin);
|
||||
// سقف صلب بعملة الدولة لا رقماً مجرّداً: 50000 كانت
|
||||
// بلا أثر على الليرة بعد حذف صفرين، وكارثية على الدينار.
|
||||
$currencyCode = (string) ($kazan['currency'] ?? 'SYP');
|
||||
$compensation = round(min($compensation, $compensationCap,
|
||||
getMoneyHardCap($currencyCode)), 2);
|
||||
|
||||
error_log("[cancel_ride_by_driver] تعويض ride=$rideId:"
|
||||
. " {$approachKm}كم×$perKm + {$billableMinutes}د×$perMin"
|
||||
. " = $compensation (سقف $compensationCap)");
|
||||
}
|
||||
}
|
||||
|
||||
if ($compensation > 0) {
|
||||
// يُخصم من الراكب كدين سالب، ويُضاف للسائق كمكافأة — الطرفان معاً.
|
||||
$negativeDebt = -$compensation;
|
||||
|
||||
$walletServer = "https://walletintaleq.intaleq.xyz";
|
||||
if (strtolower($country) == 'jordan') {
|
||||
@@ -142,7 +305,40 @@ try {
|
||||
if ($s2sCode !== 200) {
|
||||
error_log("Failed to add passenger debt via S2S: Code $s2sCode, Res: $s2sRes");
|
||||
}
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// إيصال التعويض لمحفظة السائق — النصف المفقود سابقاً.
|
||||
// كان المبلغ يُخصم من الراكب ولا يصل السائق إطلاقاً.
|
||||
// paymentID مشتق من الرحلة فيمنع ازدواج الصرف لو أُعيد الطلب.
|
||||
// ─────────────────────────────────────────────────────
|
||||
$rewardUrl = "$walletServer/v2/main/ride/driverWallet/add_s2s_reward.php";
|
||||
$chReward = curl_init($rewardUrl);
|
||||
curl_setopt_array($chReward, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
"driverID" => $driverId,
|
||||
"paymentID" => "no_show_$rideId",
|
||||
"amount" => $compensation,
|
||||
"paymentMethod" => "passenger_no_show_compensation",
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY')
|
||||
]
|
||||
]);
|
||||
$rewardRes = curl_exec($chReward);
|
||||
$rewardCode = curl_getinfo($chReward, CURLINFO_HTTP_CODE);
|
||||
curl_close($chReward);
|
||||
|
||||
if ($rewardCode !== 200) {
|
||||
// الراكب خُصم والسائق لم يُرصد — فرق يجب أن يظهر في التسوية.
|
||||
error_log("[cancel_ride_by_driver] MONEY: خُصم $compensation من"
|
||||
. " الراكب $passenger_id ولم يصل السائق $driverId"
|
||||
. " (ride=$rideId code=$rewardCode res=$rewardRes)");
|
||||
}
|
||||
|
||||
// تخزين الدين في الـ Redis لمدة 6 شهور (15552000 ثانية)
|
||||
try {
|
||||
$redisInstance = null;
|
||||
@@ -227,7 +423,11 @@ try {
|
||||
"status" => "success",
|
||||
"cancel_count" => $cancelCount,
|
||||
"is_blocked" => $isBlocked,
|
||||
"block_until" => $blockUntil
|
||||
"block_until" => $blockUntil,
|
||||
// ما استحقه السائق فعلاً عن هذه الرحلة — يعرضه التطبيق كتعويض،
|
||||
// لا كغرامة. صفر يعني أنه ألغى قبل الوصول أو قبل مدة الانتظار.
|
||||
"compensation" => $compensation ?? 0,
|
||||
"waited_minutes" => $waitedMinutes ?? 0,
|
||||
]);
|
||||
|
||||
} catch (PDOException $e) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
require_once __DIR__ . '/../pricing/pricing_helper.php';
|
||||
require_once __DIR__ . '/cancel_fee_helper.php';
|
||||
|
||||
try {
|
||||
$con_ride = Database::get('ride');
|
||||
@@ -18,8 +20,10 @@ if (!$rideId) {
|
||||
}
|
||||
|
||||
try {
|
||||
// جلب بيانات الرحلة للتحقق (مع passenger_id للإدراج في canecl)
|
||||
$stmt = $con->prepare("SELECT driver_id, passenger_id, status FROM ride WHERE id = ?");
|
||||
// SELECT * لا قائمة أعمدة: نحتاج rideTimeStart و carType لحساب الرسم،
|
||||
// وأسماء بعض الأعمدة متضاربة بين الschema والقاعدة الحية (carType /
|
||||
// car_type). النجمة تجعل الاستعلام يعمل في كل الحالات.
|
||||
$stmt = $con->prepare("SELECT * FROM ride WHERE id = ?");
|
||||
$stmt->execute([$rideId]);
|
||||
$ride = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -64,8 +68,49 @@ try {
|
||||
$insertCanecl->execute([$driverId, $passengerId, $rideId, $reason]);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 1.5 رسم الإلغاء — تعويض للسائق لا إيراد للشركة
|
||||
//
|
||||
// متماثل مع تعويض عدم الحضور في cancel_ride_by_driver.php: من ضيّع
|
||||
// وقت الآخر يعوّضه. الفرق أن الزمن يُحتسب من لحظة القبول لا من لحظة
|
||||
// الوصول — السائق كان في طريقه لا واقفاً ينتظر.
|
||||
//
|
||||
// المبلغ لا يأتي من العميل بأي حال، ويُحسب من أسعار kazan نفسها.
|
||||
// =================================================================
|
||||
$cancelFee = 0.0;
|
||||
$feeExemptReason = '';
|
||||
$feeCurrency = 'SYP';
|
||||
try {
|
||||
$cancelFee = computePassengerCancelFee(
|
||||
$con, $redisLocation ?? null, $rideId, $ride, $driverId,
|
||||
$feeExemptReason, $feeCurrency
|
||||
);
|
||||
} catch (Throwable $eFee) {
|
||||
// فشل حساب الرسم لا يمنع الإلغاء أبداً — الإلغاء حق للراكب.
|
||||
error_log("[cancel_ride_by_passenger] تعذّر حساب الرسم: " . $eFee->getMessage());
|
||||
}
|
||||
|
||||
if ($cancelFee > 0) {
|
||||
// عدّاد عدم الحضور — للتقارير وقرارات الحظر اليدوية.
|
||||
try {
|
||||
$con->prepare("UPDATE passengers SET noShowCount = noShowCount + 1 WHERE id = ?")
|
||||
->execute([$ride['passenger_id'] ?? '']);
|
||||
} catch (PDOException $eCnt) {
|
||||
error_log("[cancel_ride_by_passenger] عدّاد عدم الحضور غير مُرحَّل بعد: "
|
||||
. $eCnt->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$con->commit();
|
||||
|
||||
// التحويل المالي بعد الـcommit لا داخله: نداءات S2S شبكية قد تستغرق
|
||||
// ثوانٍ، وإبقاء المعاملة مفتوحة خلالها يقفل صفوف ride تحت ضغط.
|
||||
if ($cancelFee > 0) {
|
||||
applyCancelFeeTransfer(
|
||||
$con, $ride['passenger_id'] ?? '', $driverId, $rideId, $cancelFee
|
||||
);
|
||||
}
|
||||
|
||||
// تحديث السيرفر البعيد (Remote DB)
|
||||
if (isset($con_ride)) {
|
||||
try {
|
||||
@@ -155,7 +200,13 @@ try {
|
||||
'passenger_id' => $ride['passenger_id'] ?? '',
|
||||
]);
|
||||
|
||||
jsonSuccess(null, "Ride cancelled successfully");
|
||||
// الرسم يُرجَع للتطبيق ليعرضه صراحةً. خصم صامت من المحفظة يكتشفه
|
||||
// الراكب لاحقاً هو أسرع طريق لشكوى ولمراجعة سيئة.
|
||||
jsonSuccess([
|
||||
'cancel_fee' => $cancelFee ?? 0,
|
||||
'fee_currency' => $feeCurrency ?? 'SYP',
|
||||
'fee_exempt' => ($cancelFee ?? 0) > 0 ? '' : ($feeExemptReason ?? ''),
|
||||
], "Ride cancelled successfully");
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
|
||||
@@ -7,7 +7,7 @@ use PHPMailer\PHPMailer\Exception;
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// تضمين autoload من Composer (مجلد vendor في الجذر)
|
||||
require_once __DIR__ . '/../../../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
// استقبال البيانات من الطلب
|
||||
$passengerName = filterRequest("name");
|
||||
|
||||
@@ -9,6 +9,10 @@ try {
|
||||
|
||||
require_once __DIR__ . '/streak_helper.php';
|
||||
|
||||
// getPerKmRate / getPerMinRate / getCurrencyByCountry — كانت معرَّفة هنا
|
||||
// وانتقلت إلى pricing_helper.php ليشاركها تعويض الإلغاء بنفس الأسعار.
|
||||
require_once __DIR__ . '/../pricing/pricing_helper.php';
|
||||
|
||||
// ============================================================
|
||||
// finish_ride_updates.php — Atomic Server-to-Server
|
||||
// ============================================================
|
||||
@@ -324,6 +328,17 @@ try {
|
||||
'passenger_id' => $passengerId,
|
||||
]);
|
||||
|
||||
// ختم نهاية الرحلة — مقياس الخمول في ترتيب محرك الإسناد (وزن ١٥٪،
|
||||
// إنصافاً لمن انتظر أطول). يُكتب هنا لا عند القبول: السائق يبدأ
|
||||
// "الخمول" حين يفرغ فعلاً، لا حين ينشغل.
|
||||
try {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$redisLocation->hSet("driver:score:$driver_id", 'last_ride_end', time());
|
||||
}
|
||||
} catch (Throwable $eScore) {
|
||||
error_log("[finish_ride_updates] تعذّر ختم نهاية الرحلة: " . $eScore->getMessage());
|
||||
}
|
||||
|
||||
// 🔥 [Fix Split-Brain] تحديث القاعدة البعيدة الآن فقط، بعد أن أصبح الدفع
|
||||
// والتحديث المحلي مؤكدَين نجاحهما — يبقي الحالتين متطابقتين دائماً.
|
||||
// فشل هذا التحديث best-effort فقط (لا يُرجع الرحلة المحلية المُنجَزة فعلاً).
|
||||
@@ -423,132 +438,3 @@ try {
|
||||
jsonError("Transaction failed");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions — الآن تقرأ الأسعار من جدول kazan حسب الدولة
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* الحصول على سعر الكيلومتر حسب نوع السيارة من جدول أسعار الدولة
|
||||
*
|
||||
* 🆕 كل نوع سيارة له عمود مستقل في جدول kazan:
|
||||
* Speed → speedPrice | Comfort → comfortPrice | Lady → ladyPrice
|
||||
* Electric → electricPrice | Van → vanPrice | Delivery → deliveryPrice
|
||||
* Mishwar Vip → mishwarVipPrice | Fixed Price → fixedPrice | Awfar → awfarPrice
|
||||
*
|
||||
* @param string $carType نوع السيارة
|
||||
* @param array $countryPricing صف من جدول kazan
|
||||
* @return float
|
||||
*/
|
||||
function getPerKmRate(string $carType, array $countryPricing): float {
|
||||
// 🆕 الخريطة المباشرة: كل نوع سيارة يقابله عمود بنفس الاسم + "Price"
|
||||
$rateColumns = [
|
||||
'Comfort' => 'comfortPrice',
|
||||
'Speed' => 'speedPrice',
|
||||
'Lady' => 'ladyPrice',
|
||||
'Electric' => 'electricPrice',
|
||||
'Van' => 'vanPrice',
|
||||
'Delivery' => 'deliveryPrice',
|
||||
'Mishwar Vip' => 'mishwarVipPrice',
|
||||
'Fixed Price' => 'fixedPrice',
|
||||
'Awfar Car' => 'awfarPrice',
|
||||
];
|
||||
|
||||
$column = $rateColumns[$carType] ?? 'speedPrice';
|
||||
|
||||
// دعم التوافق مع الإصدارات القديمة (backward compatibility)
|
||||
$rate = floatval($countryPricing[$column] ?? 0);
|
||||
|
||||
// إذا كان السعر صفر أو غير موجود، نبحث في الأسماء القديمة
|
||||
if ($rate <= 0) {
|
||||
$oldColumnMap = [
|
||||
'Lady' => 'familyPrice',
|
||||
'Mishwar Vip' => 'freePrice',
|
||||
'Electric' => 'naturePrice',
|
||||
'Van' => 'heavyPrice',
|
||||
];
|
||||
$oldColumn = $oldColumnMap[$carType] ?? null;
|
||||
if ($oldColumn && isset($countryPricing[$oldColumn])) {
|
||||
$rate = floatval($countryPricing[$oldColumn]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback أخير
|
||||
if ($rate <= 0) {
|
||||
$rate = floatval($countryPricing['speedPrice'] ?? 36);
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* الحصول على سعر الدقيقة حسب وقت اليوم من جدول kazan
|
||||
*
|
||||
* 🆕 الآن يقرأ من أعمدة الدقيقة المنفصلة:
|
||||
* normalMinPrice → Normal (9 ص - 2 م / 6 م - 9 م)
|
||||
* peakMinPrice → Peak (2 م - 5 م)
|
||||
* lateMinPrice → Late (9 م - 1 ص)
|
||||
*
|
||||
* @param array $countryPricing صف من جدول kazan
|
||||
* @return float
|
||||
*/
|
||||
function getPerMinRate(array $countryPricing): float {
|
||||
$hour = (int)date('H');
|
||||
|
||||
// 🆕 قراءة الأسعار من الأعمدة الجديدة للدقيقة
|
||||
$normalMinPrice = floatval($countryPricing['normalMinPrice'] ?? 0);
|
||||
$peakMinPrice = floatval($countryPricing['peakMinPrice'] ?? 0);
|
||||
$lateMinPrice = floatval($countryPricing['lateMinPrice'] ?? 0);
|
||||
|
||||
// 🆕 دعم التوافق مع الإصدارات القديمة (latePrice, naturePrice)
|
||||
if ($lateMinPrice <= 0) $lateMinPrice = floatval($countryPricing['latePrice'] ?? 0);
|
||||
if ($normalMinPrice <= 0) $normalMinPrice = floatval($countryPricing['naturePrice'] ?? 0);
|
||||
|
||||
// Fallback: حساب سعر الدقيقة من speedPrice إذا كانت الأعمدة الجديدة فارغة
|
||||
if ($normalMinPrice <= 0) {
|
||||
$speedPrice = floatval($countryPricing['speedPrice'] ?? 36);
|
||||
$normalMinPrice = $speedPrice / 4;
|
||||
}
|
||||
if ($peakMinPrice <= 0) $peakMinPrice = $normalMinPrice * 1.15; // 15% زيادة
|
||||
if ($lateMinPrice <= 0) $lateMinPrice = $normalMinPrice * 1.25; // 25% زيادة
|
||||
|
||||
if ($hour >= 21 || $hour < 1) {
|
||||
return round($lateMinPrice, 2); // Late Night
|
||||
}
|
||||
if ($hour >= 14 && $hour <= 17) {
|
||||
return round($peakMinPrice, 2); // Peak
|
||||
}
|
||||
return round($normalMinPrice, 2); // Normal
|
||||
}
|
||||
|
||||
/**
|
||||
* تحديد رمز العملة حسب الدولة
|
||||
*
|
||||
* @param string $countryCode رمز الدولة (Syria, Egypt, Jordan, ...)
|
||||
* @return string رمز العملة (SYP, EGP, JOD, ...)
|
||||
*/
|
||||
function getCurrencyByCountry(string $countryCode): string {
|
||||
$currencies = [
|
||||
'Syria' => 'SYP',
|
||||
'Egypt' => 'EGP',
|
||||
'Jordan' => 'JOD',
|
||||
'Iraq' => 'IQD',
|
||||
'UAE' => 'AED',
|
||||
'Saudi Arabia' => 'SAR',
|
||||
'Qatar' => 'QAR',
|
||||
'Kuwait' => 'KWD',
|
||||
'Bahrain' => 'BHD',
|
||||
'Oman' => 'OMR',
|
||||
'Turkey' => 'TRY',
|
||||
'Lebanon' => 'LBP',
|
||||
'Palestine' => 'ILS',
|
||||
'Yemen' => 'YER',
|
||||
'Libya' => 'LYD',
|
||||
'Tunisia' => 'TND',
|
||||
'Algeria' => 'DZD',
|
||||
'Morocco' => 'MAD',
|
||||
'Sudan' => 'SDG',
|
||||
];
|
||||
|
||||
return $currencies[$countryCode] ?? 'SYP'; // افتراضي: ليرة سورية
|
||||
}
|
||||
?>
|
||||
@@ -3,6 +3,10 @@
|
||||
// وكائن التشفير $encryptionHelper
|
||||
require_once __DIR__ . '/../../get_connect.php';
|
||||
|
||||
// $con_tracking لا يعرّفه connect.php — كان الاستدعاء يسقط بـ
|
||||
// "Call to a member function prepare() on null".
|
||||
$con_tracking = Database::get('tracking');
|
||||
|
||||
// السماح بالوصول من أي دومين (لأن الرابط سيفتح في متصفح العميل)
|
||||
|
||||
header("Content-Type: application/json; charset=UTF-8");
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// preview_cancel_fee.php — كم سيكلّف الإلغاء؟ (بلا إلغاء)
|
||||
//
|
||||
// نقطة قراءة خالصة: تحسب الرسم بنفس دالة الإلغاء الفعلي ولا تغيّر شيئاً.
|
||||
// بدونها لا يستطيع التطبيق تحذير الراكب قبل الضغط، فيكتشف الخصم بعد
|
||||
// فوات الأوان — وهذا أسرع طريق لشكوى ومراجعة سيئة.
|
||||
//
|
||||
// تستخدم computePassengerCancelFee نفسها عمداً: أي تحذير يخالف ما يُخصم
|
||||
// فعلاً أسوأ من غياب التحذير أصلاً.
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
require_once __DIR__ . '/../pricing/pricing_helper.php';
|
||||
require_once __DIR__ . '/cancel_fee_helper.php';
|
||||
|
||||
$rideId = filterRequest("ride_id");
|
||||
|
||||
if (!$rideId) {
|
||||
jsonError("Missing Ride ID");
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare("SELECT * FROM ride WHERE id = ?");
|
||||
$stmt->execute([$rideId]);
|
||||
$ride = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$ride) {
|
||||
jsonError("Ride not found", 404);
|
||||
}
|
||||
|
||||
// رحلة بدأت فعلاً لا تُلغى أصلاً — نفس شرط الإلغاء الحقيقي.
|
||||
if (($ride['status'] ?? '') === 'Begin') {
|
||||
jsonError("Cannot cancel started ride");
|
||||
}
|
||||
|
||||
$exemptReason = '';
|
||||
$currency = 'SYP';
|
||||
$fee = computePassengerCancelFee(
|
||||
$con, $redisLocation ?? null, $rideId, $ride,
|
||||
$ride['driver_id'] ?? '0', $exemptReason, $currency
|
||||
);
|
||||
|
||||
jsonSuccess([
|
||||
'cancel_fee' => $fee,
|
||||
'fee_currency' => $currency,
|
||||
'fee_exempt' => $fee > 0 ? '' : $exemptReason,
|
||||
'will_charge' => $fee > 0,
|
||||
], "ok");
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[preview_cancel_fee] " . $e->getMessage());
|
||||
jsonError("DB Error", 500);
|
||||
}
|
||||
@@ -4,6 +4,10 @@ ob_start();
|
||||
|
||||
require_once __DIR__ . '/../../get_connect.php';
|
||||
|
||||
// $con_tracking لا يعرّفه connect.php — كان الاستدعاء يسقط بـ
|
||||
// "Call to a member function prepare() on null".
|
||||
$con_tracking = Database::get('tracking');
|
||||
|
||||
// تنظيف *جميع* مستويات التخزين المؤقت (Loop)
|
||||
// هذا يضمن التخلص من أي مسافات أو أخطاء ظهرت من ملفات الـ include
|
||||
while (ob_get_level()) {
|
||||
|
||||
Reference in New Issue
Block a user