From a1104a46958f8c2f9694805aaf85b03781a79142 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 00:57:03 +0300 Subject: [PATCH] Update: 2026-08-07 00:57:03 --- .../2026_08_07_driver_cancel_compensation.sql | 49 +++++ ...2026_08_07_kazan_cancel_penalty_policy.sql | 22 --- backend/ride/rides/acceptRide.php | 65 +++++++ backend/ride/rides/arrive_ride.php | 66 +++++++ backend/ride/rides/cancel_ride_by_driver.php | 167 ++++++++++++++---- backend/ride/rides/finish_ride_updates.php | 133 +------------- backend/schema_primary.sql | 6 +- backend/schema_ride.sql | 3 + backend/schema_tracking.sql | 3 + .../home/captin/map_driver_controller.dart | 12 ++ 10 files changed, 338 insertions(+), 188 deletions(-) create mode 100644 backend/migrations/2026_08_07_driver_cancel_compensation.sql delete mode 100644 backend/migrations/2026_08_07_kazan_cancel_penalty_policy.sql diff --git a/backend/migrations/2026_08_07_driver_cancel_compensation.sql b/backend/migrations/2026_08_07_driver_cancel_compensation.sql new file mode 100644 index 00000000..c0b4e925 --- /dev/null +++ b/backend/migrations/2026_08_07_driver_cancel_compensation.sql @@ -0,0 +1,49 @@ +-- 2026_08_07_driver_cancel_compensation.sql +-- +-- ‏تعويض السائق عند عدم حضور الراكب (إلغاء السائق بعد الوصول والانتظار). +-- ‏النموذج: التعويض = مسافة الاقتراب إلى الراكب × سعر الكيلومتر لنوع السيارة +-- ‏ + دقائق الانتظار الفعلية × سعر الدقيقة حسب وقت اليوم. +-- ‏يُخصم من محفظة الراكب ويُضاف لمحفظة السائق. الأسعار من kazan نفسه الذي +-- ‏تُسعَّر به الرحلة، عبر ride/rides/pricing_helper.php. +-- +-- ‏يستبدل هذا الملف مقاربة "الرسم الثابت" السابقة: المبلغ لم يعد يُقرأ من +-- ‏طلب العميل (كان filterRequest("penalty_fee") بلا سقف ولا تحقق)، ولم يعد +-- ‏رقماً ثابتاً — صار محسوباً من بيانات مسجَّلة على الخادم. +-- +-- ⚠️ ‏يُنفَّذ على قاعدتَي primary و ride معاً: الكود يكتب جدول ride في الاثنتين. + +-- ══════════════════════════════════════════════════════════════ +-- ١) سياسة التعويض لكل دولة (قاعدة primary فقط — kazan يُقرأ عبر $con) +-- ══════════════════════════════════════════════════════════════ +ALTER TABLE `kazan` + ADD COLUMN `cancelCompensationCap` VARCHAR(10) + CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci + NOT NULL DEFAULT '0' + COMMENT 'سقف تعويض عدم حضور الراكب بعملة الدولة. 0 = التعويض معطّل بالكامل' + AFTER `startPrice`, + ADD COLUMN `cancelMinWaitMinutes` TINYINT UNSIGNED + NOT NULL DEFAULT 5 + COMMENT 'أقل دقائق انتظار بعد الوصول قبل أن يستحق السائق تعويضاً' + AFTER `cancelCompensationCap`; + +-- ══════════════════════════════════════════════════════════════ +-- ٢) بيانات الوصول والاقتراب (قاعدتا primary و ride) +-- ══════════════════════════════════════════════════════════════ +ALTER TABLE `ride` + ADD COLUMN `driverArrivedAt` DATETIME NULL DEFAULT NULL + COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع', + ADD COLUMN `pickupApproachKm` FLOAT NOT NULL DEFAULT 0 + COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)'; + +-- ══════════════════════════════════════════════════════════════ +-- ٣) تطبيق الإيقاف على الخادم (قاعدتا primary و ride) +-- ‏كان الإيقاف يُكتب في تخزين تطبيق السائق وحده، فيُفكّ بإعادة التثبيت. +-- ══════════════════════════════════════════════════════════════ +ALTER TABLE `driver` + ADD COLUMN `blockedUntil` DATETIME NULL DEFAULT NULL + COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف'; + +-- ‏للتراجع: +-- ALTER TABLE `kazan` DROP COLUMN `cancelCompensationCap`, DROP COLUMN `cancelMinWaitMinutes`; +-- ALTER TABLE `ride` DROP COLUMN `driverArrivedAt`, DROP COLUMN `pickupApproachKm`; +-- ALTER TABLE `driver` DROP COLUMN `blockedUntil`; diff --git a/backend/migrations/2026_08_07_kazan_cancel_penalty_policy.sql b/backend/migrations/2026_08_07_kazan_cancel_penalty_policy.sql deleted file mode 100644 index f29f24fd..00000000 --- a/backend/migrations/2026_08_07_kazan_cancel_penalty_policy.sql +++ /dev/null @@ -1,22 +0,0 @@ --- 2026_08_07_kazan_cancel_penalty_policy.sql --- --- ‏سبب الترحيل: كان مبلغ غرامة الإلغاء في ride/rides/cancel_ride_by_driver.php --- ‏يُقرأ من طلب العميل مباشرة (filterRequest("penalty_fee")) ويُخصم من محفظة --- ‏الراكب بلا سقف ولا تحقق — أي أن طلباً مصاغاً يدوياً كان يقدر يخصم أي مبلغ --- ‏من أي راكب. صار المبلغ يُقرأ من هنا فقط، ولا يقبل الخادم أي قيمة من العميل. --- --- ‏القيمة الافتراضية '0' تحفظ السلوك الحالي حرفياً: لا عميل يرسل penalty_fee --- ‏اليوم، فالغرامة لا تُطبَّق فعلياً. تفعيلها قرار إداري صريح بتحديث الصف. --- --- ‏القاعدة المستهدفة: primary فقط — الكود يقرأ kazan عبر $con، وجدول kazan في --- ‏قاعدتي ride/tracking بنية أقدم ومختلفة الأعمدة (لا يُستخدم في هذا المسار). - -ALTER TABLE `kazan` - ADD COLUMN `cancelPenaltyFee` VARCHAR(10) - CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci - NOT NULL DEFAULT '0' - COMMENT 'غرامة إلغاء السائق بعد القبول، تُخصم من محفظة الراكب. 0 = معطّلة' - AFTER `startPrice`; - --- ‏للتراجع: --- ALTER TABLE `kazan` DROP COLUMN `cancelPenaltyFee`; diff --git a/backend/ride/rides/acceptRide.php b/backend/ride/rides/acceptRide.php index 7d0746ef..7a4e930b 100644 --- a/backend/ride/rides/acceptRide.php +++ b/backend/ride/rides/acceptRide.php @@ -46,6 +46,29 @@ if (!in_array($status, $allowedStatuses, true)) { error_log("[accept_ride] DriverID=$driverId attempting RideID=$rideId"); try { + // ═══════════════════════════════════════════════════════════ + // STEP A0 — تطبيق الإيقاف على الخادم + // ‏كان الإيقاف بعد ٣ إلغاءات يُكتب في تخزين تطبيق السائق وحده، فيُفكّ + // ‏بمسح بيانات التطبيق أو إعادة تثبيته. هذه هي البوابة الحقيقية: سائق + // ‏موقوف لا يستطيع أن يفوز برحلة مهما فعل بتطبيقه. + // ═══════════════════════════════════════════════════════════ + 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; + } + } catch (PDOException $eBlock) { + // ‏العمود غير موجود بعد (قبل تشغيل ترحيل 2026_08_07) — لا نمنع القبول + // ‏بسبب فحصٍ لا يمكن إجراؤه، لكن نسجّله بوضوح. + error_log("[accept_ride] تعذّر فحص blockedUntil (شغّل" + . " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eBlock->getMessage()); + } + // ═══════════════════════════════════════════════════════════ // STEP A — القفل على ride DB (المرجع الأساسي) // Optimistic lock: نغير فقط إذا status لا يزال 'waiting' أو 'wait' @@ -87,6 +110,48 @@ try { error_log("[accept_ride] primary DB sync WARNING: " . $eSync->getMessage()); } + // ═══════════════════════════════════════════════════════════ + // STEP B1 — تثبيت مسافة الاقتراب لحظة القبول + // ‏هذه هي المسافة التي سيقطعها السائق نحو الراكب، وهي أساس تعويض عدم + // ‏الحضور لاحقاً. تُقاس الآن حصراً: عند الإلغاء يكون السائق واقفاً عند + // ‏نقطة الالتقاط فتصير المسافة صفراً، فلا يمكن استنتاجها بأثر رجعي. + // ‏مصدر الموقع هو Redis لا الطلب — حتى لا يتحكم العميل بالمبلغ. + // ═══════════════════════════════════════════════════════════ + 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 ($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 { + error_log("[accept_ride] تعذّر قياس مسافة الاقتراب RideID=$rideId" + . " — التعويض سيحتسب زمن الانتظار وحده"); + } + } catch (PDOException $eApproach) { + error_log("[accept_ride] pickupApproachKm WARNING: " . $eApproach->getMessage()); + } + // ═══════════════════════════════════════════════════════════ // STEP C — driver_orders (INSERT أو UPDATE بسطر واحد آمن) // ON DUPLICATE KEY يمنع race condition ثانية على هذا الجدول diff --git a/backend/ride/rides/arrive_ride.php b/backend/ride/rides/arrive_ride.php index b900c764..58c1f808 100644 --- a/backend/ride/rides/arrive_ride.php +++ b/backend/ride/rides/arrive_ride.php @@ -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,68 @@ try { $stmtLocal->execute([$rideId, $driverId]); } + // ───────────────────────────────────────────────────────────── + // 2.1 ختم لحظة الوصول — أساس تعويض عدم حضور الراكب + // + // ‏فحص المسافة في التطبيق (100م) على العميل، فيمكن تجاوزه بموقع مزيَّف + // ‏ثم انتظار خمس دقائق وقبض تعويض بلا وجود حقيقي. لذلك يتحقق الخادم + // ‏هنا بنفسه من موقع السائق في Redis قبل أن يختم الوقت. + // + // ‏إن تعذّر التحقق (Redis غير متاح أو الموقع مفقود) لا نمنع الوصول — + // ‏الحالة تُحدَّث والراكب يُشعَر كالمعتاد — لكن لا نختم وقتاً يترتب عليه + // ‏مال لا نستطيع إثباته. النتيجة: لا تعويض لهذه الرحلة، وسجلّ واضح. + // ───────────────────────────────────────────────────────────── + try { + $stmtRide = $con->prepare("SELECT * FROM ride WHERE id = ? AND driver_id = ?"); + $stmtRide->execute([$rideId, $driverId]); + $rideRow = $stmtRide->fetch(PDO::FETCH_ASSOC); + + if (is_array($rideRow) + && array_key_exists('driverArrivedAt', $rideRow) + && empty($rideRow['driverArrivedAt'])) { + + [$pLat, $pLng] = array_pad( + array_map('trim', explode(',', (string) $rideRow['start_location'], 2)), 2, null + ); + + $canStamp = false; + if (isset($redisLocation) && $redisLocation + && 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) { + // ‏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 هو المرجع لهذا الحقل */ } + } 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()); + } + // 3. جلب بيانات الراكب للإرسال // نستخدم con_ride لضمان الدقة $stmtPas = $con_ride->prepare("SELECT passenger_id FROM ride WHERE id = ?"); diff --git a/backend/ride/rides/cancel_ride_by_driver.php b/backend/ride/rides/cancel_ride_by_driver.php index b454b0da..a219b017 100644 --- a/backend/ride/rides/cancel_ride_by_driver.php +++ b/backend/ride/rides/cancel_ride_by_driver.php @@ -10,22 +10,33 @@ 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"); -// ‏مبلغ الغرامة لا يُقرأ من الطلب إطلاقاً. كان سابقاً filterRequest("penalty_fee") -// ‏فيُخصم من محفظة الراكب بلا سقف ولا تحقق. صار يُحسم من سياسة الخادم أدناه. -// ‏إن وصل المفتاح من عميل فهو إما نسخة قديمة أو محاولة عبث — نسجّله ونتجاهله. -if (filterRequest("penalty_fee") !== null && filterRequest("penalty_fee") !== '') { +// ‏هذا المسار ليس تغريماً للسائق — هو تعويض له عن عدم حضور الراكب: +// ‏السائق وصل نقطة الالتقاط، انتظر المدة المقررة، ولم يحضر الراكب. فيُعوَّض +// ‏عن المسافة التي قطعها نحوه + دقائق الانتظار، ويُخصم المبلغ من الراكب. +// ‏عقوبة السائق كثير الإلغاء شيء آخر تماماً: إيقاف مؤقت، لا مال (القسم ٢). +// +// ‏المبلغ لا يُقرأ من الطلب إطلاقاً. كان سابقاً filterRequest("penalty_fee") +// ‏فيُخصم من محفظة الراكب بلا سقف ولا تحقق — أي بيد العميل. +if (filterRequest("penalty_fee") !== null) { error_log("[cancel_ride_by_driver] SECURITY: تجاهل penalty_fee من العميل" - . " (ride=$rideId driver=$driverId) — السياسة تُقرأ من kazan فقط"); + . " (ride=$rideId driver=$driverId) — التعويض يُحسب على الخادم"); } // ‏سقف صلب فوق أي قيمة في قاعدة البيانات — يمنع خطأ إدارياً أو صفاً تالفاً -// ‏من تحويل الغرامة إلى خصم كارثي. -const CANCEL_PENALTY_ABSOLUTE_CAP = 50000.0; +// ‏من تحويل التعويض إلى خصم كارثي. +const CANCEL_COMPENSATION_ABSOLUTE_CAP = 50000.0; + +// ‏حد أقصى لدقائق الانتظار المحتسبة: سائق نسي الرحلة مفتوحة ساعتين لا +// ‏يتحول انتظاره إلى فاتورة على الراكب. +const CANCEL_MAX_BILLABLE_WAIT_MINUTES = 20; // تثبيت الحالة $statusText = "CancelFromDriverAfterApply"; @@ -42,9 +53,19 @@ try { // 0. التحقق من الملكية — الرحلة يجب أن تكون مُسندة لهذا السائق // ‏بدون هذا الفحص كان أي driver_id يقدر يلغي أي رحلة ويغرّم راكبها. // --------------------------------------------------------- - $stmtOwn = $con->prepare("SELECT driver_id FROM ride WHERE id = ? FOR UPDATE"); + // ‏SELECT * عمداً لا قائمة أعمدة: أعمدة التعويض (driverArrivedAt / + // ‏pickupApproachKm) قد لا تكون مُرحَّلة بعد، واسم عمود نوع السيارة + // ‏نفسه متضارب بين الملفات (carType في الschema، car_type في + // ‏finish_ride_updates.php). النجمة تجعل الاستعلام يعمل في كل الحالات + // ‏وتترك الغياب يُعالَج كقيمة مفقودة بدل أن يُسقط الإلغاء كله. + $stmtOwn = $con->prepare("SELECT * FROM ride WHERE id = ? FOR UPDATE"); $stmtOwn->execute([$rideId]); - $assignedDriver = $stmtOwn->fetchColumn(); + $rideRow = $stmtOwn->fetch(PDO::FETCH_ASSOC); + + $assignedDriver = is_array($rideRow) ? $rideRow['driver_id'] : false; + + // ‏نوع السيارة تحت أي من الاسمين + $rideCarType = (string) ($rideRow['carType'] ?? $rideRow['car_type'] ?? 'Speed'); if ($assignedDriver === false) { $con->rollBack(); @@ -94,7 +115,20 @@ try { if ($cancelCount >= 3) { $isBlocked = true; $blockUntil = date('Y-m-d H:i:s', strtotime('+4 hours')); - // يمكنك هنا تحديث حالة السائق في جدول driver إذا لزم الأمر + + // ‏تثبيت الإيقاف على الخادم. كان يُكتب في تخزين تطبيق السائق وحده + // ‏(statusDriverLocation='blocked')، فيُفكّ بمسح البيانات أو إعادة + // ‏التثبيت. الآن acceptRide.php يرفض القبول ما دام blockedUntil مستقبلياً. + // ‏GREATEST يمنع إلغاءً جديداً من تقصير إيقاف سارٍ أطول. + 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] تعذّر تثبيت الإيقاف (شغّل" + . " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eBlock->getMessage()); + } } // --------------------------------------------------------- @@ -132,34 +166,58 @@ try { notifyPassengerOnRideServer($passenger_id, $socketPayload); } - // 4.1. غرامة الإلغاء على الراكب — المبلغ من سياسة الخادم لا من العميل - // Resolve country + penalty policy (kazan هو مصدر الحقيقة الوحيد) - // ‏قراءة متسامحة: لو نزل الكود قبل ترحيل cancelPenaltyFee، لا نُسقط - // ‏الإلغاء كله — نرجع لقراءة الدولة وحدها ونعتبر الغرامة صفراً. - try { - $stmtKazan = $con->prepare("SELECT country, cancelPenaltyFee FROM kazan LIMIT 1"); - $stmtKazan->execute(); - $kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: []; - } catch (PDOException $eKazan) { - error_log("[cancel_ride_by_driver] cancelPenaltyFee غير موجود بعد" - . " (شغّل migrations/2026_08_07_kazan_cancel_penalty_policy.sql): " - . $eKazan->getMessage()); - $stmtKazan = $con->prepare("SELECT country FROM kazan LIMIT 1"); - $stmtKazan->execute(); - $kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: []; - } + // ───────────────────────────────────────────────────────── + // 4.1. تعويض السائق عن عدم حضور الراكب + // ‏= مسافة الاقتراب × سعر الكيلومتر لنوع السيارة + // + دقائق الانتظار × سعر الدقيقة حسب وقت اليوم + // ‏كل المدخلات مسجَّلة على الخادم؛ لا شيء منها من العميل. + // ───────────────────────────────────────────────────────── + $stmtKazan = $con->prepare("SELECT * FROM kazan LIMIT 1"); + $stmtKazan->execute(); + $kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: []; $country = $kazan['country'] ?? 'Jordan'; - $penaltyFee = (float) ($kazan['cancelPenaltyFee'] ?? 0); - if ($penaltyFee > CANCEL_PENALTY_ABSOLUTE_CAP) { - error_log("[cancel_ride_by_driver] kazan.cancelPenaltyFee=$penaltyFee" - . " تجاوز السقف الصلب — قُصّ إلى " . CANCEL_PENALTY_ABSOLUTE_CAP); - $penaltyFee = CANCEL_PENALTY_ABSOLUTE_CAP; + $compensationCap = (float) ($kazan['cancelCompensationCap'] ?? 0); + $minWaitMinutes = (int) ($kazan['cancelMinWaitMinutes'] ?? 5); + + $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] إلغاء قبل تسجيل الوصول — بلا تعويض" + . " ride=$rideId driver=$driverId"); + } else { + $waitedMinutes = (int) floor((time() - strtotime($arrivedAt)) / 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); + $compensation = round(min($compensation, $compensationCap, + CANCEL_COMPENSATION_ABSOLUTE_CAP), 2); + + error_log("[cancel_ride_by_driver] تعويض ride=$rideId:" + . " {$approachKm}كم×$perKm + {$billableMinutes}د×$perMin" + . " = $compensation (سقف $compensationCap)"); + } } - if ($penaltyFee > 0) { - // إضافة القيمة كدين سالب في المحفظة - $negativeDebt = -$penaltyFee; + if ($compensation > 0) { + // ‏يُخصم من الراكب كدين سالب، ويُضاف للسائق كمكافأة — الطرفان معاً. + $negativeDebt = -$compensation; $walletServer = "https://walletintaleq.intaleq.xyz"; if (strtolower($country) == 'jordan') { @@ -193,7 +251,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; @@ -278,7 +369,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) { diff --git a/backend/ride/rides/finish_ride_updates.php b/backend/ride/rides/finish_ride_updates.php index aed117da..c7eb7925 100644 --- a/backend/ride/rides/finish_ride_updates.php +++ b/backend/ride/rides/finish_ride_updates.php @@ -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 // ============================================================ @@ -423,132 +427,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'; // افتراضي: ليرة سورية -} -?> \ No newline at end of file diff --git a/backend/schema_primary.sql b/backend/schema_primary.sql index 2d7a657d..93f04673 100644 --- a/backend/schema_primary.sql +++ b/backend/schema_primary.sql @@ -387,6 +387,7 @@ DROP TABLE IF EXISTS `driver`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `driver` ( + `blockedUntil` datetime DEFAULT NULL COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف', `idn` int NOT NULL AUTO_INCREMENT, `id` varchar(100) NOT NULL, `phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL, @@ -839,7 +840,8 @@ CREATE TABLE `kazan` ( `fuelPrice` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'سعر الوقود المرجعي', `currency` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'SYP' COMMENT 'رمز العملة: SYP, EGP, JOD, IQD, ...', `startPrice` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '0' COMMENT 'فتحة العداد', - `cancelPenaltyFee` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '0' COMMENT 'غرامة إلغاء السائق بعد القبول، تُخصم من محفظة الراكب. 0 = معطّلة', + `cancelCompensationCap` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '0' COMMENT 'سقف تعويض عدم حضور الراكب بعملة الدولة. 0 = التعويض معطّل بالكامل', + `cancelMinWaitMinutes` tinyint unsigned NOT NULL DEFAULT '5' COMMENT 'أقل دقائق انتظار بعد الوصول قبل أن يستحق السائق تعويضاً', -- ========== 🚗 أسعار الكيلومتر لكل نوع سيارة ========== `speedPrice` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'سعر الكيلومتر - Speed', @@ -1457,6 +1459,8 @@ DROP TABLE IF EXISTS `ride`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ride` ( + `driverArrivedAt` datetime DEFAULT NULL COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع', + `pickupApproachKm` float NOT NULL DEFAULT '0' COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)', `id` int NOT NULL AUTO_INCREMENT, `start_location` varchar(255) NOT NULL, `end_location` varchar(255) NOT NULL, diff --git a/backend/schema_ride.sql b/backend/schema_ride.sql index e7a6a4e3..691f8824 100644 --- a/backend/schema_ride.sql +++ b/backend/schema_ride.sql @@ -384,6 +384,7 @@ DROP TABLE IF EXISTS `driver`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `driver` ( + `blockedUntil` datetime DEFAULT NULL COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف', `idn` int NOT NULL AUTO_INCREMENT, `id` varchar(100) NOT NULL, `phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL, @@ -1346,6 +1347,8 @@ DROP TABLE IF EXISTS `ride`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ride` ( + `driverArrivedAt` datetime DEFAULT NULL COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع', + `pickupApproachKm` float NOT NULL DEFAULT '0' COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)', `id` int NOT NULL AUTO_INCREMENT, `start_location` varchar(255) NOT NULL, `end_location` varchar(255) NOT NULL, diff --git a/backend/schema_tracking.sql b/backend/schema_tracking.sql index cefbe50d..7eb5d4a8 100644 --- a/backend/schema_tracking.sql +++ b/backend/schema_tracking.sql @@ -385,6 +385,7 @@ DROP TABLE IF EXISTS `driver`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `driver` ( + `blockedUntil` datetime DEFAULT NULL COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف', `idn` int NOT NULL AUTO_INCREMENT, `id` varchar(100) NOT NULL, `phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL, @@ -1386,6 +1387,8 @@ DROP TABLE IF EXISTS `ride`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `ride` ( + `driverArrivedAt` datetime DEFAULT NULL COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع', + `pickupApproachKm` float NOT NULL DEFAULT '0' COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)', `id` int NOT NULL AUTO_INCREMENT, `start_location` varchar(255) NOT NULL, `end_location` varchar(255) NOT NULL, diff --git a/siro_driver/lib/controller/home/captin/map_driver_controller.dart b/siro_driver/lib/controller/home/captin/map_driver_controller.dart index e379978d..61e6bf93 100755 --- a/siro_driver/lib/controller/home/captin/map_driver_controller.dart +++ b/siro_driver/lib/controller/home/captin/map_driver_controller.dart @@ -535,6 +535,18 @@ class MapDriverController extends GetxController ); if (response['status'] == 'success') { + // 💰 التعويض عن عدم حضور الراكب — يحسبه الخادم من مسافة الاقتراب + // ودقائق الانتظار، ويُضيفه لمحفظة السائق. صفر يعني أن الإلغاء وقع + // قبل تسجيل الوصول أو قبل اكتمال مدة الانتظار المقررة. + final num compensation = response['compensation'] ?? 0; + if (compensation > 0) { + final int waited = response['waited_minutes'] ?? 0; + mySnackbarSuccess( + "تم تعويضك $compensation عن المسافة و $waited دقيقة انتظار." + .tr, + ); + } + // 🔥🔥 معالجة الحظر (The Penalty Logic) 🔥🔥 bool isBlocked = response['is_blocked'] ?? false;