From 0cb1ab0ea37e474982bed09185186654f0606300 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 01:06:09 +0300 Subject: [PATCH] Update: 2026-08-07 01:06:09 --- ...07_driver_cancel_compensation_primary.sql} | 14 ++- ..._08_07_driver_cancel_compensation_ride.sql | 44 +++++++++ backend/ride/rides/acceptRide.php | 89 ++++++++++--------- backend/ride/rides/arrive_ride.php | 41 ++++----- backend/ride/rides/cancel_ride_by_driver.php | 83 +++++++++++++---- 5 files changed, 190 insertions(+), 81 deletions(-) rename backend/migrations/{2026_08_07_driver_cancel_compensation.sql => 2026_08_07_driver_cancel_compensation_primary.sql} (76%) create mode 100644 backend/migrations/2026_08_07_driver_cancel_compensation_ride.sql diff --git a/backend/migrations/2026_08_07_driver_cancel_compensation.sql b/backend/migrations/2026_08_07_driver_cancel_compensation_primary.sql similarity index 76% rename from backend/migrations/2026_08_07_driver_cancel_compensation.sql rename to backend/migrations/2026_08_07_driver_cancel_compensation_primary.sql index c0b4e925..6a6aa94a 100644 --- a/backend/migrations/2026_08_07_driver_cancel_compensation.sql +++ b/backend/migrations/2026_08_07_driver_cancel_compensation_primary.sql @@ -4,13 +4,13 @@ -- ‏النموذج: التعويض = مسافة الاقتراب إلى الراكب × سعر الكيلومتر لنوع السيارة -- ‏ + دقائق الانتظار الفعلية × سعر الدقيقة حسب وقت اليوم. -- ‏يُخصم من محفظة الراكب ويُضاف لمحفظة السائق. الأسعار من kazan نفسه الذي --- ‏تُسعَّر به الرحلة، عبر ride/rides/pricing_helper.php. +-- ‏تُسعَّر به الرحلة، عبر ride/pricing/pricing_helper.php. -- -- ‏يستبدل هذا الملف مقاربة "الرسم الثابت" السابقة: المبلغ لم يعد يُقرأ من -- ‏طلب العميل (كان filterRequest("penalty_fee") بلا سقف ولا تحقق)، ولم يعد -- ‏رقماً ثابتاً — صار محسوباً من بيانات مسجَّلة على الخادم. -- --- ⚠️ ‏يُنفَّذ على قاعدتَي primary و ride معاً: الكود يكتب جدول ride في الاثنتين. +-- ⚠️ ‏هذا الملف لقاعدة primary (jorSiroDB) حصراً. الملف التوأم _ride.sql يُنفَّذ على قاعدة الرحلات (intaleq-ridesDB). -- ══════════════════════════════════════════════════════════════ -- ١) سياسة التعويض لكل دولة (قاعدة primary فقط — kazan يُقرأ عبر $con) @@ -28,6 +28,12 @@ ALTER TABLE `kazan` -- ══════════════════════════════════════════════════════════════ -- ٢) بيانات الوصول والاقتراب (قاعدتا primary و ride) +-- +-- ‏هذان العمودان للتوثيق النهائي فقط ولا يُكتبان أثناء الرحلة: القيمتان +-- ‏تعيشان في هاش Redis نفسه الذي يستخدمه السوكِت لحالة الرحلة +-- ‏(ride:$id:state، الحقول pickup_approach_km و driver_arrived_at)، +-- ‏وتُثبَّتان هنا كتابةً واحدة مدمجة في جملة تحديث الحالة عند الإلغاء. +-- ‏أي: صفر كتابات إضافية على المسار الحار (القبول والوصول). -- ══════════════════════════════════════════════════════════════ ALTER TABLE `ride` ADD COLUMN `driverArrivedAt` DATETIME NULL DEFAULT NULL @@ -38,6 +44,10 @@ ALTER TABLE `ride` -- ══════════════════════════════════════════════════════════════ -- ٣) تطبيق الإيقاف على الخادم (قاعدتا primary و ride) -- ‏كان الإيقاف يُكتب في تخزين تطبيق السائق وحده، فيُفكّ بإعادة التثبيت. +-- +-- ‏مسار الفحص في acceptRide.php هو مفتاح Redis بـ TTL (driver:$id:blocked) +-- ‏فينتهي الإيقاف تلقائياً بلا كرون تنظيف وبلا استعلام على كل قبول. +-- ‏هذا العمود سجل دائم للمراجعة الإدارية، يُكتب في الإلغاء الثالث فقط. -- ══════════════════════════════════════════════════════════════ ALTER TABLE `driver` ADD COLUMN `blockedUntil` DATETIME NULL DEFAULT NULL diff --git a/backend/migrations/2026_08_07_driver_cancel_compensation_ride.sql b/backend/migrations/2026_08_07_driver_cancel_compensation_ride.sql new file mode 100644 index 00000000..6e9ff6c8 --- /dev/null +++ b/backend/migrations/2026_08_07_driver_cancel_compensation_ride.sql @@ -0,0 +1,44 @@ +-- 2026_08_07_driver_cancel_compensation.sql +-- +-- ‏تعويض السائق عند عدم حضور الراكب (إلغاء السائق بعد الوصول والانتظار). +-- ‏النموذج: التعويض = مسافة الاقتراب إلى الراكب × سعر الكيلومتر لنوع السيارة +-- ‏ + دقائق الانتظار الفعلية × سعر الدقيقة حسب وقت اليوم. +-- ‏يُخصم من محفظة الراكب ويُضاف لمحفظة السائق. الأسعار من kazan نفسه الذي +-- ‏تُسعَّر به الرحلة، عبر ride/pricing/pricing_helper.php. +-- +-- ‏يستبدل هذا الملف مقاربة "الرسم الثابت" السابقة: المبلغ لم يعد يُقرأ من +-- ‏طلب العميل (كان filterRequest("penalty_fee") بلا سقف ولا تحقق)، ولم يعد +-- ‏رقماً ثابتاً — صار محسوباً من بيانات مسجَّلة على الخادم. +-- +-- ⚠️ ‏هذا الملف لقاعدة ride (intaleq-ridesDB) حصراً. جدول kazan لا يُمسّ هنا: بنيته في قاعدة الرحلات أقدم ومختلفة الأعمدة، ولا يُقرأ في هذا المسار. + +-- ══════════════════════════════════════════════════════════════ +-- ٢) بيانات الوصول والاقتراب (قاعدتا primary و ride) +-- +-- ‏هذان العمودان للتوثيق النهائي فقط ولا يُكتبان أثناء الرحلة: القيمتان +-- ‏تعيشان في هاش Redis نفسه الذي يستخدمه السوكِت لحالة الرحلة +-- ‏(ride:$id:state، الحقول pickup_approach_km و driver_arrived_at)، +-- ‏وتُثبَّتان هنا كتابةً واحدة مدمجة في جملة تحديث الحالة عند الإلغاء. +-- ‏أي: صفر كتابات إضافية على المسار الحار (القبول والوصول). +-- ══════════════════════════════════════════════════════════════ +ALTER TABLE `ride` + ADD COLUMN `driverArrivedAt` DATETIME NULL DEFAULT NULL + COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع', + ADD COLUMN `pickupApproachKm` FLOAT NOT NULL DEFAULT 0 + COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)'; + +-- ══════════════════════════════════════════════════════════════ +-- ٣) تطبيق الإيقاف على الخادم (قاعدتا primary و ride) +-- ‏كان الإيقاف يُكتب في تخزين تطبيق السائق وحده، فيُفكّ بإعادة التثبيت. +-- +-- ‏مسار الفحص في acceptRide.php هو مفتاح Redis بـ TTL (driver:$id:blocked) +-- ‏فينتهي الإيقاف تلقائياً بلا كرون تنظيف وبلا استعلام على كل قبول. +-- ‏هذا العمود سجل دائم للمراجعة الإدارية، يُكتب في الإلغاء الثالث فقط. +-- ══════════════════════════════════════════════════════════════ +ALTER TABLE `driver` + ADD COLUMN `blockedUntil` DATETIME NULL DEFAULT NULL + COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف'; + +-- ‏للتراجع: +-- ALTER TABLE `ride` DROP COLUMN `driverArrivedAt`, DROP COLUMN `pickupApproachKm`; +-- ALTER TABLE `driver` DROP COLUMN `blockedUntil`; diff --git a/backend/ride/rides/acceptRide.php b/backend/ride/rides/acceptRide.php index 7a4e930b..1a799855 100644 --- a/backend/ride/rides/acceptRide.php +++ b/backend/ride/rides/acceptRide.php @@ -52,21 +52,21 @@ try { // ‏بمسح بيانات التطبيق أو إعادة تثبيته. هذه هي البوابة الحقيقية: سائق // ‏موقوف لا يستطيع أن يفوز برحلة مهما فعل بتطبيقه. // ═══════════════════════════════════════════════════════════ + // ‏الفحص من Redis لا MySQL: هذا المسار يُستدعى على كل محاولة قبول من كل + // ‏سائق، والإيقاف حالة نادرة قصيرة العمر. مفتاح بـ TTL يجعل انتهاء + // ‏الإيقاف تلقائياً بلا كرون ولا استعلام تنظيف. + // ‏MySQL يبقى السجل الدائم للمراجعة الإدارية، ولا يُقرأ هنا. try { - $stmtBlock = $con->prepare("SELECT blockedUntil FROM driver WHERE id = ?"); - $stmtBlock->execute([$driverId]); - $blockedUntil = $stmtBlock->fetchColumn(); - - if (!empty($blockedUntil) && strtotime($blockedUntil) > time()) { - error_log("[accept_ride] DriverID=$driverId موقوف حتى $blockedUntil — رُفض القبول"); - printFailure("Your account is temporarily suspended until $blockedUntil"); - exit; + 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; + } } - } catch (PDOException $eBlock) { - // ‏العمود غير موجود بعد (قبل تشغيل ترحيل 2026_08_07) — لا نمنع القبول - // ‏بسبب فحصٍ لا يمكن إجراؤه، لكن نسجّله بوضوح. - error_log("[accept_ride] تعذّر فحص blockedUntil (شغّل" - . " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eBlock->getMessage()); + } catch (Throwable $eBlock) { + error_log("[accept_ride] تعذّر فحص الإيقاف: " . $eBlock->getMessage()); } // ═══════════════════════════════════════════════════════════ @@ -111,44 +111,49 @@ try { } // ═══════════════════════════════════════════════════════════ - // STEP B1 — تثبيت مسافة الاقتراب لحظة القبول - // ‏هذه هي المسافة التي سيقطعها السائق نحو الراكب، وهي أساس تعويض عدم - // ‏الحضور لاحقاً. تُقاس الآن حصراً: عند الإلغاء يكون السائق واقفاً عند - // ‏نقطة الالتقاط فتصير المسافة صفراً، فلا يمكن استنتاجها بأثر رجعي. - // ‏مصدر الموقع هو Redis لا الطلب — حتى لا يتحكم العميل بالمبلغ. + // STEP B1 — تثبيت مسافة الاقتراب لحظة القبول (في Redis لا MySQL) + // + // ‏هذه المسافة أساس تعويض عدم الحضور لاحقاً، ويجب أن تُقاس الآن حصراً: + // ‏عند الإلغاء يكون السائق واقفاً عند نقطة الالتقاط فتصير صفراً. + // + // ‏لكنها قيمة عابرة تُقرأ مرة واحدة عند نهاية الرحلة — فلا تستحق كتابتين + // ‏في MySQL على أسخن مسار في النظام. تُحفظ في نفس الهاش الذي يستخدمه + // ‏السوكِت لحالة الرحلة (ride:$id:state)، وتُثبَّت في MySQL كتابةً واحدة + // ‏مع تحديث الحالة النهائي عند الإلغاء. + // + // ‏hSet لا يمسح حقول السوكِت الأخرى في نفس الهاش، وTTL الـ24 ساعة الذي + // ‏يضبطه السوكِت يغطي هذه الحقول تلقائياً. // ═══════════════════════════════════════════════════════════ try { - $stmtPickup = $con->prepare("SELECT start_location FROM ride WHERE id = ?"); - $stmtPickup->execute([$rideId]); - $pickupRaw = (string) $stmtPickup->fetchColumn(); - - [$pickupLat, $pickupLng] = array_pad( - array_map('trim', explode(',', $pickupRaw, 2)), 2, null - ); - $approachKm = 0.0; - if (isset($redisLocation) && $redisLocation - && 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 (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) { - $sqlApproach = "UPDATE ride SET pickupApproachKm = ? WHERE id = ?"; - $con->prepare($sqlApproach)->execute([$approachKm, $rideId]); - try { - $con_ride->prepare($sqlApproach)->execute([$approachKm, $rideId]); - } catch (PDOException $e) { /* primary يكفي لحساب التعويض */ } - } else { + if ($approachKm <= 0) { error_log("[accept_ride] تعذّر قياس مسافة الاقتراب RideID=$rideId" . " — التعويض سيحتسب زمن الانتظار وحده"); } - } catch (PDOException $eApproach) { + } catch (Throwable $eApproach) { + // ‏قياس التعويض لا يُسقط قبول الرحلة أبداً. error_log("[accept_ride] pickupApproachKm WARNING: " . $eApproach->getMessage()); } diff --git a/backend/ride/rides/arrive_ride.php b/backend/ride/rides/arrive_ride.php index 58c1f808..551c6aab 100644 --- a/backend/ride/rides/arrive_ride.php +++ b/backend/ride/rides/arrive_ride.php @@ -43,23 +43,23 @@ try { // ‏الحالة تُحدَّث والراكب يُشعَر كالمعتاد — لكن لا نختم وقتاً يترتب عليه // ‏مال لا نستطيع إثباته. النتيجة: لا تعويض لهذه الرحلة، وسجلّ واضح. // ───────────────────────────────────────────────────────────── + // ‏الختم في Redis لا MySQL — قيمة عابرة تُقرأ مرة واحدة عند الإلغاء، + // ‏وتُثبَّت في MySQL كتابةً واحدة هناك. hSetNx يجعله ذرّياً: نداء ثانٍ + // ‏لا يعيد ضبط عدّاد الانتظار. try { - $stmtRide = $con->prepare("SELECT * FROM ride WHERE id = ? AND driver_id = ?"); - $stmtRide->execute([$rideId, $driverId]); - $rideRow = $stmtRide->fetch(PDO::FETCH_ASSOC); + $stateKey = "ride:$rideId:state"; - if (is_array($rideRow) - && array_key_exists('driverArrivedAt', $rideRow) - && empty($rideRow['driverArrivedAt'])) { + 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) $rideRow['start_location'], 2)), 2, null + array_map('trim', explode(',', (string) $stmtPickup->fetchColumn(), 2)), 2, null ); $canStamp = false; - if (isset($redisLocation) && $redisLocation - && is_numeric($pLat) && is_numeric($pLng)) { - + if (is_numeric($pLat) && is_numeric($pLng)) { $loc = $redisLocation->hGetAll("driver:public:$driverId"); if (!empty($loc['lat']) && !empty($loc['lng'])) { $gap = getDistanceBetweenPoints( @@ -75,23 +75,20 @@ try { } if ($canStamp) { - // ‏WHERE driverArrivedAt IS NULL يجعله ذرّياً: نداء ثانٍ لا - // ‏يعيد ضبط عدّاد الانتظار. - $sqlStamp = "UPDATE ride SET driverArrivedAt = NOW() - WHERE id = ? AND driver_id = ? AND driverArrivedAt IS NULL"; - $con->prepare($sqlStamp)->execute([$rideId, $driverId]); - try { - $con_ride->prepare($sqlStamp)->execute([$rideId, $driverId]); - } catch (PDOException $e) { /* primary هو المرجع لهذا الحقل */ } + $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 (PDOException $eStamp) { - // ‏العمود غير مُرحَّل بعد — الوصول يعمل، التعويض معطّل. - error_log("[arrive_ride] تعذّر ختم driverArrivedAt (شغّل" - . " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eStamp->getMessage()); + } catch (Throwable $eStamp) { + // ‏فشل الختم لا يمنع إشعار الراكب بالوصول. + error_log("[arrive_ride] تعذّر ختم وقت الوصول: " . $eStamp->getMessage()); } // 3. جلب بيانات الراكب للإرسال diff --git a/backend/ride/rides/cancel_ride_by_driver.php b/backend/ride/rides/cancel_ride_by_driver.php index a219b017..31b0bc09 100644 --- a/backend/ride/rides/cancel_ride_by_driver.php +++ b/backend/ride/rides/cancel_ride_by_driver.php @@ -67,6 +67,22 @@ try { // ‏نوع السيارة تحت أي من الاسمين $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"); @@ -118,15 +134,32 @@ try { // ‏تثبيت الإيقاف على الخادم. كان يُكتب في تخزين تطبيق السائق وحده // ‏(statusDriverLocation='blocked')، فيُفكّ بمسح البيانات أو إعادة - // ‏التثبيت. الآن acceptRide.php يرفض القبول ما دام blockedUntil مستقبلياً. - // ‏GREATEST يمنع إلغاءً جديداً من تقصير إيقاف سارٍ أطول. + // ‏التثبيت. الآن 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] تعذّر تثبيت الإيقاف (شغّل" + error_log("[cancel_ride_by_driver] تعذّر تسجيل الإيقاف في MySQL (شغّل" . " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eBlock->getMessage()); } } @@ -134,14 +167,35 @@ try { // --------------------------------------------------------- // 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]); + } } // 🆕 تصفير التتابع لأن السائق ألغى الرحلة @@ -180,21 +234,20 @@ try { $compensationCap = (float) ($kazan['cancelCompensationCap'] ?? 0); $minWaitMinutes = (int) ($kazan['cancelMinWaitMinutes'] ?? 5); - $compensation = 0.0; + // ‏$approachKm و$arrivedTs قُرئا من Redis قراءةً واحدة في القسم صفر. + $compensation = 0.0; $waitedMinutes = 0; - $approachKm = (float) ($rideRow['pickupApproachKm'] ?? 0); - $arrivedAt = $rideRow['driverArrivedAt'] ?? null; if ($compensationCap <= 0) { // ‏التعويض معطّل لهذه الدولة — الافتراضي حتى تضبطه الإدارة. error_log("[cancel_ride_by_driver] التعويض معطّل (cancelCompensationCap=0)" . " ride=$rideId"); - } elseif (empty($arrivedAt)) { - // ‏لا ختم وصول ⇒ السائق ألغى قبل أن يصل. لا انتظار، فلا تعويض. - error_log("[cancel_ride_by_driver] إلغاء قبل تسجيل الوصول — بلا تعويض" + } elseif ($arrivedTs <= 0) { + // ‏لا ختم وصول ⇒ السائق ألغى قبل أن يصل، أو تعذّر إثبات وصوله. + error_log("[cancel_ride_by_driver] إلغاء بلا ختم وصول — بلا تعويض" . " ride=$rideId driver=$driverId"); } else { - $waitedMinutes = (int) floor((time() - strtotime($arrivedAt)) / 60); + $waitedMinutes = (int) floor((time() - $arrivedTs) / 60); if ($waitedMinutes < $minWaitMinutes) { error_log("[cancel_ride_by_driver] انتظار $waitedMinutes د < الحد"