From 70772cf0d258eb866f0852b1a22a8c8da4890589 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 02:32:15 +0300 Subject: [PATCH] Update: 2026-08-07 02:32:14 --- backend/ride/rides/cancel_fee_helper.php | 21 ++- .../ride/rides/cancel_ride_by_passenger.php | 9 +- backend/ride/rides/preview_cancel_fee.php | 54 +++++++ docs/04_features/CANCELLATION_POLICY_AR.md | 145 ++++++++++++++++++ siro_rider/lib/constant/links.dart | 5 + .../home/map/ride_lifecycle_controller.dart | 54 +++++++ 6 files changed, 283 insertions(+), 5 deletions(-) create mode 100644 backend/ride/rides/preview_cancel_fee.php create mode 100644 docs/04_features/CANCELLATION_POLICY_AR.md diff --git a/backend/ride/rides/cancel_fee_helper.php b/backend/ride/rides/cancel_fee_helper.php index 0e080718..8e40e9f0 100644 --- a/backend/ride/rides/cancel_fee_helper.php +++ b/backend/ride/rides/cancel_fee_helper.php @@ -31,12 +31,24 @@ function computePassengerCancelFee( $rideId, array $ride, $driverId, - string &$exemptReason + string &$exemptReason, + &$currency = null ): float { + // ‏العملة تُحسم من الدولة دائماً، حتى مع الإعفاء: التطبيق يحتاجها ليعرض + // ‏"الإلغاء مجاني" أو "سيُخصم 1.20 دينار" بوحدة صحيحة. + $currency = 'SYP'; // ── إعفاء ١: لا سائق قَبِل بعد ────────────────────────── // ‏لا أحد تحرّك، فلا أحد يستحق تعويضاً. وهذه أغلب حالات الإلغاء. if (empty($driverId) || (string) $driverId === '0') { $exemptReason = 'no_driver_assigned'; + try { + $stmtCur = $con->prepare("SELECT currency, country FROM kazan LIMIT 1"); + $stmtCur->execute(); + $rowCur = $stmtCur->fetch(PDO::FETCH_ASSOC) ?: []; + $currency = !empty($rowCur['currency']) + ? (string) $rowCur['currency'] + : getCurrencyByCountry((string) ($rowCur['country'] ?? 'Syria')); + } catch (PDOException $e) { /* الافتراضي يكفي */ } return 0.0; } @@ -45,6 +57,11 @@ function computePassengerCancelFee( $stmtKazan->execute(); $kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: []; + // ‏العمود currency هو المرجع، وgetCurrencyByCountry احتياط إن كان فارغاً. + $currency = !empty($kazan['currency']) + ? (string) $kazan['currency'] + : getCurrencyByCountry((string) ($kazan['country'] ?? 'Syria')); + $cap = (float) ($kazan['passengerCancelCap'] ?? 0); if ($cap <= 0) { // ‏الرسم معطّل لهذه الدولة — الافتراضي حتى تضبطه الإدارة. @@ -103,7 +120,7 @@ function computePassengerCancelFee( $fee = round(min($fee, $cap, PASSENGER_CANCEL_ABSOLUTE_CAP), 2); error_log("[cancel_fee] ride=$rideId رسم إلغاء: {$approachKm}كم" - . " + {$billableMinutes}د = $fee (سقف $cap)"); + . " + {$billableMinutes}د = $fee $currency (سقف $cap)"); return max(0.0, $fee); } diff --git a/backend/ride/rides/cancel_ride_by_passenger.php b/backend/ride/rides/cancel_ride_by_passenger.php index 3afe5c20..dd3921f3 100644 --- a/backend/ride/rides/cancel_ride_by_passenger.php +++ b/backend/ride/rides/cancel_ride_by_passenger.php @@ -79,9 +79,11 @@ try { // ================================================================= $cancelFee = 0.0; $feeExemptReason = ''; + $feeCurrency = 'SYP'; try { $cancelFee = computePassengerCancelFee( - $con, $redisLocation ?? null, $rideId, $ride, $driverId, $feeExemptReason + $con, $redisLocation ?? null, $rideId, $ride, $driverId, + $feeExemptReason, $feeCurrency ); } catch (Throwable $eFee) { // ‏فشل حساب الرسم لا يمنع الإلغاء أبداً — الإلغاء حق للراكب. @@ -201,8 +203,9 @@ try { // ‏الرسم يُرجَع للتطبيق ليعرضه صراحةً. خصم صامت من المحفظة يكتشفه // ‏الراكب لاحقاً هو أسرع طريق لشكوى ولمراجعة سيئة. jsonSuccess([ - 'cancel_fee' => $cancelFee ?? 0, - 'fee_exempt' => ($cancelFee ?? 0) > 0 ? '' : ($feeExemptReason ?? ''), + 'cancel_fee' => $cancelFee ?? 0, + 'fee_currency' => $feeCurrency ?? 'SYP', + 'fee_exempt' => ($cancelFee ?? 0) > 0 ? '' : ($feeExemptReason ?? ''), ], "Ride cancelled successfully"); } catch (PDOException $e) { diff --git a/backend/ride/rides/preview_cancel_fee.php b/backend/ride/rides/preview_cancel_fee.php new file mode 100644 index 00000000..80996638 --- /dev/null +++ b/backend/ride/rides/preview_cancel_fee.php @@ -0,0 +1,54 @@ +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); +} diff --git a/docs/04_features/CANCELLATION_POLICY_AR.md b/docs/04_features/CANCELLATION_POLICY_AR.md new file mode 100644 index 00000000..73f8667d --- /dev/null +++ b/docs/04_features/CANCELLATION_POLICY_AR.md @@ -0,0 +1,145 @@ +# سياسة الإلغاء والتعويض — سيرو + +> **الغرض:** مرجع واحد لخدمة العملاء، وأساس صفحة ويب عامة تُشرح للمستخدمين. +> **آخر تحديث:** 2026-08-07 +> **الحالة:** مبنية في الكود، **معطّلة افتراضياً** حتى تضبط الإدارة السقوف. + +--- + +## المبدأ الحاكم + +**من ضيّع وقت الآخر يعوّضه، والمال يذهب للطرف المتضرر لا للشركة.** + +هذا يفرّق سيرو عن نموذج "غرامة تذهب للمنصة". السائق الذي انتظر يُعوَّض، والراكب الذي انتظر سائقاً متأخراً لا يُغرَّم. + +--- + +## أولاً: إلغاء الراكب بعد قبول السائق + +### متى يكون الإلغاء مجانياً؟ + +| الحالة | الشرح للمستخدم | +|---|---| +| **لم يقبل أي سائق بعد** | لم يتحرك أحد نحوك، فلا أحد يستحق تعويضاً. وهذه أغلب حالات الإلغاء. | +| **خلال أول دقيقتين من قبول السائق** | مهلة لتصحيح خطأ في الوجهة أو تغيير الرأي. | +| **مرّت 10 دقائق ولم يصل السائق** | التأخير من السائق لا منك — الإلغاء مجاني. | +| **الرسم معطّل في بلدك** | الإدارة لم تفعّل السياسة بعد. | + +### متى يُحتسب رسم؟ + +عندما يكون السائق قد **قَبِل الرحلة وتحرّك نحوك**، ومضت مهلة الدقيقتين، ولم يتأخر عليك. + +### كيف يُحسب؟ + +``` +الرسم = المسافة التي قطعها السائق نحوك × سعر الكيلومتر لنوع السيارة + + الدقائق منذ قبوله × سعر الدقيقة حسب وقت اليوم +``` + +- الأسعار هي **نفسها** المستخدمة في تسعير الرحلات (جدول `kazan` لكل دولة). +- **العملة:** بعملة بلد التشغيل — ليرة سورية (SYP)، دينار أردني (JOD)، جنيه مصري (EGP)… +- **حد أقصى للدقائق المحتسبة: 20 دقيقة.** سائق قَبِل ونسي الرحلة ساعة لا يتحول نسيانه إلى فاتورة عليك. +- **سقف أعلى** يحدده كل بلد (`passengerCancelCap`)، وفوقه سقف صلب في الكود يمنع أي خطأ إداري من خصم كارثي. + +### ماذا يحدث بعد الإلغاء؟ + +1. يُخصم المبلغ من محفظتك كدَين. +2. **يُضاف نفس المبلغ لمحفظة السائق** — ليس إيراداً للشركة. +3. يزيد عدّاد "عدم الحضور" في حسابك (للمراجعة الإدارية فقط، لا عقوبة آلية). + +### التحذير المسبق + +التطبيق **يعرض المبلغ ويطلب تأكيدك قبل الإلغاء**. لا خصم صامت. إن تعذّر الاتصال بالخادم لحظتها، يمضي الإلغاء بلا تحذير — لأن حبسك في رحلة بسبب عطل تقني أسوأ. + +--- + +## ثانياً: تعويض السائق عند عدم حضور الراكب + +### متى يستحق السائق تعويضاً؟ + +عندما يصل نقطة الالتقاط، **ينتظر 5 دقائق على الأقل**، ولا يحضر الراكب، فيلغي الرحلة. + +### كيف يُحسب؟ + +``` +التعويض = مسافة الاقتراب إلى الراكب × سعر الكيلومتر + + دقائق الانتظار × سعر الدقيقة +``` + +نفس المعادلة والأسعار. الحد الأقصى للدقائق المحتسبة 20 دقيقة أيضاً. + +### شروط مهمة + +- **الوصول يتحقق منه الخادم** من موقع السائق الفعلي (ضمن 300 متر من نقطة الالتقاط). لا يكفي أن يضغط السائق "وصلت". +- **إن تعذّر إثبات الوصول** (خدمة الموقع معطّلة مثلاً) فلا تعويض — لا يُصرف مال لا يمكن إثبات استحقاقه. +- إلغاء السائق **قبل** الوصول: لا تعويض إطلاقاً. + +--- + +## ثالثاً: عقوبة السائق كثير الإلغاء + +منفصلة تماماً عن المال. **لا غرامة على السائق أبداً.** + +| الشرط | العقوبة | +|---|---| +| 3 إلغاءات بعد القبول خلال 24 ساعة | إيقاف استقبال الطلبات **4 ساعات** | + +الإيقاف مطبَّق على الخادم: السائق الموقوف لا يستطيع قبول أي رحلة حتى لو أعاد تثبيت التطبيق. + +--- + +## رابعاً: التسرب خارج المنصة + +سلوك يتكرر فيه: قَبِل السائق ← أُلغيت الرحلة ← ولم تبدأ قط، مع **نفس الراكب مراراً**. + +| التكرار | درجة الاشتباه | +|---|---| +| مرة واحدة | منخفضة (للإلغاء أسباب مشروعة كثيرة) | +| مرتان | متوسطة | +| ثلاث فأكثر | مرتفعة → **مراجعة بشرية** | + +**العقوبة:** خفض أولوية السائق في توزيع الرحلات — يخسر الطلبات الخاصة والقريبة تدريجياً. **لا حظر ولا خصم مالي.** وتزول العقوبة تلقائياً حين ينظف سجله خلال 30 يوماً. + +--- + +## المعاملات القابلة للضبط (لكل دولة، جدول `kazan`) + +| المعامل | الافتراضي | المعنى | +|---|---|---| +| `passengerCancelCap` | `0` (معطّل) | سقف رسم إلغاء الراكب | +| `passengerCancelFreeMinutes` | `2` | دقائق الإلغاء المجاني بعد القبول | +| `driverLateExemptMinutes` | `10` | بعدها يصير الإلغاء مجانياً لتأخر السائق | +| `cancelCompensationCap` | `0` (معطّل) | سقف تعويض عدم حضور الراكب | +| `cancelMinWaitMinutes` | `5` | أقل انتظار يستحق السائق بعده تعويضاً | + +> ⚠️ القيمة `0` في السقوف تعني **تعطيلاً كاملاً**. لا يُخصم ولا يُصرف شيء حتى تضبطها الإدارة صراحةً. + +--- + +## أسئلة متوقعة من العملاء + +**«ألغيت لأن السائق لم يتحرك من مكانه — لماذا خُصم مني؟»** +إن مرّت 10 دقائق ولم يصل، الإلغاء مجاني تلقائياً. إن خُصم رغم ذلك فالرحلة تحتاج مراجعة — ارفعها للفريق التقني بمعرّف الرحلة. + +**«لماذا الرسم مختلف كل مرة؟»** +لأنه ليس رقماً ثابتاً — يعتمد على المسافة التي قطعها السائق فعلاً وعلى الوقت المنقضي. رحلة ألغيت بعد 3 دقائق تختلف عن أخرى بعد 12. + +**«هل تأخذ الشركة هذا المبلغ؟»** +لا. يذهب كاملاً لمحفظة السائق. + +**«ألغى السائق عليّ — هل أُعوَّض؟»** +لا يوجد تعويض للراكب حالياً عند إلغاء السائق. لكن السائق كثير الإلغاء يُوقَف 4 ساعات آلياً. + +--- + +## مراجع تقنية + +| الموضوع | الملف | +|---|---| +| رسم إلغاء الراكب | `backend/ride/rides/cancel_fee_helper.php` | +| معاينة الرسم قبل الإلغاء | `backend/ride/rides/preview_cancel_fee.php` | +| تعويض عدم الحضور + الإيقاف | `backend/ride/rides/cancel_ride_by_driver.php` | +| إثبات الوصول | `backend/ride/rides/arrive_ride.php` | +| كشف التسرب | `backend/bot/cron_leak_detection.php` | +| الأسعار المشتركة | `backend/ride/pricing/pricing_helper.php` | +| الترحيلات | `backend/migrations/2026_08_07_*.sql` | diff --git a/siro_rider/lib/constant/links.dart b/siro_rider/lib/constant/links.dart index d69742b6..fc67d2c7 100644 --- a/siro_rider/lib/constant/links.dart +++ b/siro_rider/lib/constant/links.dart @@ -228,6 +228,11 @@ class AppLink { static String get addCancelRideFromPassenger => "$rideServerSide/cancelRide/add.php"; static String get cancelRide => "$rideServerSide/cancelRide/get.php"; + + /// معاينة رسم الإلغاء قبل تنفيذه — قراءة خالصة لا تلغي شيئاً. + /// تستخدم نفس دالة الحساب التي يستخدمها الإلغاء الفعلي، فما يُعرض + /// للراكب هو ما سيُخصم منه بالضبط. + static String get previewCancelFee => "$server/ride/rides/preview_cancel_fee.php"; //-----------------ridessss------------------ static String get addRides => "$rideServerSide/ride/rides/add.php"; static String get getRides => "$rideServerSide/ride/rides/get.php"; diff --git a/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart b/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart index 46761780..3b51c230 100644 --- a/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart +++ b/siro_rider/lib/controller/home/map/ride_lifecycle_controller.dart @@ -3991,6 +3991,55 @@ class RideLifecycleController extends GetxController { } } + /// ‏يسأل الخادم كم سيكلّف الإلغاء، ويعرض تأكيداً إن كان هناك رسم. + /// + /// ‏يرجع true للمتابعة وfalse للتراجع. الإعفاءات الثلاثة (لا سائق قَبِل، + /// ‏ضمن الدقائق المجانية، السائق تأخّر) يحسمها الخادم فلا حاجة لتكرار + /// ‏المنطق هنا — وتكراره كان سيسمح للنسختين بالتباعد. + /// + /// ‏عند أي فشل شبكي نُكمل الإلغاء بلا تحذير: حبس الراكب في رحلة بسبب + /// ‏عطل عندنا أسوأ من خصم لم يُحذَّر منه. + Future _confirmCancelFee() async { + if (rideId == 'yet' || rideId == null) return true; + + try { + final res = await CRUD().post( + link: AppLink.previewCancelFee, + payload: {"ride_id": rideId.toString()}, + ); + + final data = res is Map ? (res['message'] ?? res['data']) : null; + if (data is! Map) return true; + + final bool willCharge = data['will_charge'] == true; + if (!willCharge) return true; + + final String amount = data['cancel_fee']?.toString() ?? '0'; + final String currency = data['fee_currency']?.toString() ?? ''; + + bool confirmed = false; + await Get.defaultDialog( + title: 'تأكيد الإلغاء'.tr, + middleText: + 'السائق في طريقه إليك. سيُخصم $amount $currency كتعويض له عن' + ' المسافة والوقت.' + .tr, + textConfirm: 'إلغاء الرحلة'.tr, + textCancel: 'تراجع'.tr, + onConfirm: () { + confirmed = true; + Get.back(); + }, + onCancel: () => confirmed = false, + ); + + return confirmed; + } catch (e) { + Log.print("⚠️ تعذّرت معاينة رسم الإلغاء: $e"); + return true; + } + } + Future cancelRide() async { if (selectedReasonIndex == -1) { mySnackbarWarning('Please select a reason first'.tr); @@ -4006,6 +4055,11 @@ class RideLifecycleController extends GetxController { finalReason = otherReasonController.text.trim(); } + // ‏تحذير الرسم قبل أي تفكيك للحالة. لو جاء بعده، ورفض الراكب + // ‏الاستمرار، لوجد رحلته وقد أُلغيت على الشاشة بلا رجعة. + final bool proceed = await _confirmCancelFee(); + if (!proceed) return; + Get.back(); if (isCancelRidePageShown) { mapEngine.changeCancelRidePageShow();