Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-09 16:56:13 +03:00
parent 95e2e4f35d
commit b64debaa88
1058 changed files with 164327 additions and 113928 deletions
+152
View File
@@ -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
View File
@@ -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);
+63
View File
@@ -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 = ?");
+186
View File
@@ -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)");
}
}
+220 -20
View File
@@ -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");
+15 -129
View File
@@ -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");
+54
View File
@@ -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()) {