Update: 2026-08-07 01:06:09

This commit is contained in:
Hamza-Ayed
2026-08-07 01:06:09 +03:00
parent a1104a4695
commit 0cb1ab0ea3
5 changed files with 190 additions and 81 deletions
@@ -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
@@ -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`;
+47 -42
View File
@@ -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());
}
+19 -22
View File
@@ -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. جلب بيانات الراكب للإرسال
+68 -15
View File
@@ -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 د < الحد"