Update: 2026-08-07 00:57:03

This commit is contained in:
Hamza-Ayed
2026-08-07 00:57:03 +03:00
parent 6e451e8b71
commit a1104a4695
10 changed files with 338 additions and 188 deletions
@@ -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`;
@@ -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`;
+65
View File
@@ -46,6 +46,29 @@ if (!in_array($status, $allowedStatuses, true)) {
error_log("[accept_ride] DriverID=$driverId attempting RideID=$rideId"); error_log("[accept_ride] DriverID=$driverId attempting RideID=$rideId");
try { 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 (المرجع الأساسي) // STEP A — القفل على ride DB (المرجع الأساسي)
// Optimistic lock: نغير فقط إذا status لا يزال 'waiting' أو 'wait' // Optimistic lock: نغير فقط إذا status لا يزال 'waiting' أو 'wait'
@@ -87,6 +110,48 @@ try {
error_log("[accept_ride] primary DB sync WARNING: " . $eSync->getMessage()); 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 بسطر واحد آمن) // STEP C — driver_orders (INSERT أو UPDATE بسطر واحد آمن)
// ON DUPLICATE KEY يمنع race condition ثانية على هذا الجدول // ON DUPLICATE KEY يمنع race condition ثانية على هذا الجدول
+66
View File
@@ -17,6 +17,10 @@ if (!$rideId || !$driverId) {
exit; exit;
} }
// ‏نصف قطر قبول ختم الوصول. أوسع من فحص الـ100م في التطبيق بهامش يستوعب
// ‏ضعف الـGPS بين المباني، وأضيق من أن يسمح بالختم من حي آخر.
const ARRIVAL_STAMP_RADIUS_KM = 0.30;
try { try {
// 1. تحديث الحالة في السيرفر البعيد (Remote DB - con_ride) // 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'"); $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]); $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. جلب بيانات الراكب للإرسال // 3. جلب بيانات الراكب للإرسال
// نستخدم con_ride لضمان الدقة // نستخدم con_ride لضمان الدقة
$stmtPas = $con_ride->prepare("SELECT passenger_id FROM ride WHERE id = ?"); $stmtPas = $con_ride->prepare("SELECT passenger_id FROM ride WHERE id = ?");
+131 -36
View File
@@ -10,22 +10,33 @@ try {
require_once __DIR__ . '/streak_helper.php'; require_once __DIR__ . '/streak_helper.php';
// ‏نفس أسعار الكيلومتر والدقيقة التي تُحسب بها الرحلة في finish_ride_updates.php
require_once __DIR__ . '/../pricing/pricing_helper.php';
$rideId = filterRequest("ride_id"); $rideId = filterRequest("ride_id");
$driverId = filterRequest("driver_id"); $driverId = filterRequest("driver_id");
$reason = filterRequest("reason"); $reason = filterRequest("reason");
$passengerToken = filterRequest("passenger_token"); $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 من العميل" 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"; $statusText = "CancelFromDriverAfterApply";
@@ -42,9 +53,19 @@ try {
// 0. التحقق من الملكية — الرحلة يجب أن تكون مُسندة لهذا السائق // 0. التحقق من الملكية — الرحلة يجب أن تكون مُسندة لهذا السائق
// ‏بدون هذا الفحص كان أي driver_id يقدر يلغي أي رحلة ويغرّم راكبها. // ‏بدون هذا الفحص كان أي 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]); $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) { if ($assignedDriver === false) {
$con->rollBack(); $con->rollBack();
@@ -94,7 +115,20 @@ try {
if ($cancelCount >= 3) { if ($cancelCount >= 3) {
$isBlocked = true; $isBlocked = true;
$blockUntil = date('Y-m-d H:i:s', strtotime('+4 hours')); $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); notifyPassengerOnRideServer($passenger_id, $socketPayload);
} }
// 4.1. غرامة الإلغاء على الراكب — المبلغ من سياسة الخادم لا من العميل // ─────────────────────────────────────────────────────────
// Resolve country + penalty policy (kazan هو مصدر الحقيقة الوحيد) // 4.1. تعويض السائق عن عدم حضور الراكب
// ‏قراءة متسامحة: لو نزل الكود قبل ترحيل cancelPenaltyFee، لا نُسقط // ‏= مسافة الاقتراب × سعر الكيلومتر لنوع السيارة
// ‏الإلغاء كله — نرجع لقراءة الدولة وحدها ونعتبر الغرامة صفراً. // + دقائق الانتظار × سعر الدقيقة حسب وقت اليوم
try { // ‏كل المدخلات مسجَّلة على الخادم؛ لا شيء منها من العميل.
$stmtKazan = $con->prepare("SELECT country, cancelPenaltyFee FROM kazan LIMIT 1"); // ─────────────────────────────────────────────────────────
$stmtKazan->execute(); $stmtKazan = $con->prepare("SELECT * FROM kazan LIMIT 1");
$kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: []; $stmtKazan->execute();
} catch (PDOException $eKazan) { $kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: [];
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) ?: [];
}
$country = $kazan['country'] ?? 'Jordan'; $country = $kazan['country'] ?? 'Jordan';
$penaltyFee = (float) ($kazan['cancelPenaltyFee'] ?? 0); $compensationCap = (float) ($kazan['cancelCompensationCap'] ?? 0);
if ($penaltyFee > CANCEL_PENALTY_ABSOLUTE_CAP) { $minWaitMinutes = (int) ($kazan['cancelMinWaitMinutes'] ?? 5);
error_log("[cancel_ride_by_driver] kazan.cancelPenaltyFee=$penaltyFee"
. " تجاوز السقف الصلب — قُصّ إلى " . CANCEL_PENALTY_ABSOLUTE_CAP); $compensation = 0.0;
$penaltyFee = CANCEL_PENALTY_ABSOLUTE_CAP; $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) { if ($compensation > 0) {
// إضافة القيمة كدين سالب في المحفظة // ‏يُخصم من الراكب كدين سالب، ويُضاف للسائق كمكافأة — الطرفان معاً.
$negativeDebt = -$penaltyFee; $negativeDebt = -$compensation;
$walletServer = "https://walletintaleq.intaleq.xyz"; $walletServer = "https://walletintaleq.intaleq.xyz";
if (strtolower($country) == 'jordan') { if (strtolower($country) == 'jordan') {
@@ -193,7 +251,40 @@ try {
if ($s2sCode !== 200) { if ($s2sCode !== 200) {
error_log("Failed to add passenger debt via S2S: Code $s2sCode, Res: $s2sRes"); 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 ثانية) // تخزين الدين في الـ Redis لمدة 6 شهور (15552000 ثانية)
try { try {
$redisInstance = null; $redisInstance = null;
@@ -278,7 +369,11 @@ try {
"status" => "success", "status" => "success",
"cancel_count" => $cancelCount, "cancel_count" => $cancelCount,
"is_blocked" => $isBlocked, "is_blocked" => $isBlocked,
"block_until" => $blockUntil "block_until" => $blockUntil,
// ‏ما استحقه السائق فعلاً عن هذه الرحلة — يعرضه التطبيق كتعويض،
// ‏لا كغرامة. صفر يعني أنه ألغى قبل الوصول أو قبل مدة الانتظار.
"compensation" => $compensation ?? 0,
"waited_minutes" => $waitedMinutes ?? 0,
]); ]);
} catch (PDOException $e) { } catch (PDOException $e) {
+4 -129
View File
@@ -9,6 +9,10 @@ try {
require_once __DIR__ . '/streak_helper.php'; 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 // finish_ride_updates.php — Atomic Server-to-Server
// ============================================================ // ============================================================
@@ -423,132 +427,3 @@ try {
jsonError("Transaction failed"); 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'; // افتراضي: ليرة سورية
}
?>
+5 -1
View File
@@ -387,6 +387,7 @@ DROP TABLE IF EXISTS `driver`;
/*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */; /*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `driver` ( CREATE TABLE `driver` (
`blockedUntil` datetime DEFAULT NULL COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف',
`idn` int NOT NULL AUTO_INCREMENT, `idn` int NOT NULL AUTO_INCREMENT,
`id` varchar(100) NOT NULL, `id` varchar(100) NOT NULL,
`phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci 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 'سعر الوقود المرجعي', `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, ...', `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 'فتحة العداد', `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', `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 */; /*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */; /*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ride` ( CREATE TABLE `ride` (
`driverArrivedAt` datetime DEFAULT NULL COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع',
`pickupApproachKm` float NOT NULL DEFAULT '0' COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)',
`id` int NOT NULL AUTO_INCREMENT, `id` int NOT NULL AUTO_INCREMENT,
`start_location` varchar(255) NOT NULL, `start_location` varchar(255) NOT NULL,
`end_location` varchar(255) NOT NULL, `end_location` varchar(255) NOT NULL,
+3
View File
@@ -384,6 +384,7 @@ DROP TABLE IF EXISTS `driver`;
/*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */; /*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `driver` ( CREATE TABLE `driver` (
`blockedUntil` datetime DEFAULT NULL COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف',
`idn` int NOT NULL AUTO_INCREMENT, `idn` int NOT NULL AUTO_INCREMENT,
`id` varchar(100) NOT NULL, `id` varchar(100) NOT NULL,
`phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci 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 */; /*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */; /*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ride` ( CREATE TABLE `ride` (
`driverArrivedAt` datetime DEFAULT NULL COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع',
`pickupApproachKm` float NOT NULL DEFAULT '0' COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)',
`id` int NOT NULL AUTO_INCREMENT, `id` int NOT NULL AUTO_INCREMENT,
`start_location` varchar(255) NOT NULL, `start_location` varchar(255) NOT NULL,
`end_location` varchar(255) NOT NULL, `end_location` varchar(255) NOT NULL,
+3
View File
@@ -385,6 +385,7 @@ DROP TABLE IF EXISTS `driver`;
/*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */; /*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `driver` ( CREATE TABLE `driver` (
`blockedUntil` datetime DEFAULT NULL COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف',
`idn` int NOT NULL AUTO_INCREMENT, `idn` int NOT NULL AUTO_INCREMENT,
`id` varchar(100) NOT NULL, `id` varchar(100) NOT NULL,
`phone` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci 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 */; /*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */; /*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `ride` ( CREATE TABLE `ride` (
`driverArrivedAt` datetime DEFAULT NULL COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع',
`pickupApproachKm` float NOT NULL DEFAULT '0' COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)',
`id` int NOT NULL AUTO_INCREMENT, `id` int NOT NULL AUTO_INCREMENT,
`start_location` varchar(255) NOT NULL, `start_location` varchar(255) NOT NULL,
`end_location` varchar(255) NOT NULL, `end_location` varchar(255) NOT NULL,
@@ -535,6 +535,18 @@ class MapDriverController extends GetxController
); );
if (response['status'] == 'success') { 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) 🔥🔥 // 🔥🔥 معالجة الحظر (The Penalty Logic) 🔥🔥
bool isBlocked = response['is_blocked'] ?? false; bool isBlocked = response['is_blocked'] ?? false;