439 lines
22 KiB
PHP
439 lines
22 KiB
PHP
<?php
|
||
// cancel_ride_by_driver.php
|
||
require_once __DIR__ . '/../../connect.php';
|
||
|
||
try {
|
||
$con_ride = Database::get('ride');
|
||
} catch (Exception $e) {
|
||
error_log("[cancel_ride_by_driver] Failed to connect to Ride Database: " . $e->getMessage());
|
||
}
|
||
|
||
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) {
|
||
error_log("[cancel_ride_by_driver] SECURITY: تجاهل penalty_fee من العميل"
|
||
. " (ride=$rideId driver=$driverId) — التعويض يُحسب على الخادم");
|
||
}
|
||
|
||
// سقف صلب فوق أي قيمة في قاعدة البيانات — يمنع خطأ إدارياً أو صفاً تالفاً
|
||
// من تحويل التعويض إلى خصم كارثي.
|
||
const CANCEL_COMPENSATION_ABSOLUTE_CAP = 50000.0;
|
||
|
||
// حد أقصى لدقائق الانتظار المحتسبة: سائق نسي الرحلة مفتوحة ساعتين لا
|
||
// يتحول انتظاره إلى فاتورة على الراكب.
|
||
const CANCEL_MAX_BILLABLE_WAIT_MINUTES = 20;
|
||
|
||
// تثبيت الحالة
|
||
$statusText = "CancelFromDriverAfterApply";
|
||
|
||
if (!$rideId || !$driverId) {
|
||
jsonError("Missing parameters");
|
||
exit;
|
||
}
|
||
|
||
try {
|
||
$con->beginTransaction();
|
||
|
||
// ---------------------------------------------------------
|
||
// 0. التحقق من الملكية — الرحلة يجب أن تكون مُسندة لهذا السائق
|
||
// بدون هذا الفحص كان أي driver_id يقدر يلغي أي رحلة ويغرّم راكبها.
|
||
// ---------------------------------------------------------
|
||
// SELECT * عمداً لا قائمة أعمدة: أعمدة التعويض (driverArrivedAt /
|
||
// pickupApproachKm) قد لا تكون مُرحَّلة بعد، واسم عمود نوع السيارة
|
||
// نفسه متضارب بين الملفات (carType في الschema، car_type في
|
||
// finish_ride_updates.php). النجمة تجعل الاستعلام يعمل في كل الحالات
|
||
// وتترك الغياب يُعالَج كقيمة مفقودة بدل أن يُسقط الإلغاء كله.
|
||
$stmtOwn = $con->prepare("SELECT * FROM ride WHERE id = ? FOR UPDATE");
|
||
$stmtOwn->execute([$rideId]);
|
||
$rideRow = $stmtOwn->fetch(PDO::FETCH_ASSOC);
|
||
|
||
$assignedDriver = is_array($rideRow) ? $rideRow['driver_id'] : false;
|
||
|
||
// نوع السيارة تحت أي من الاسمين
|
||
$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");
|
||
exit;
|
||
}
|
||
|
||
if ((string) $assignedDriver !== (string) $driverId) {
|
||
$con->rollBack();
|
||
error_log("[cancel_ride_by_driver] SECURITY: رفض إلغاء رحلة غير مُسندة"
|
||
. " (ride=$rideId caller=$driverId assigned=$assignedDriver)");
|
||
jsonError("Ride is not assigned to this driver");
|
||
exit;
|
||
}
|
||
|
||
// ---------------------------------------------------------
|
||
// 1. معالجة driver_orders (Insert or Update)
|
||
// ---------------------------------------------------------
|
||
$checkStmt = $con->prepare("SELECT order_id FROM driver_orders WHERE order_id = ? AND driver_id = ?");
|
||
$checkStmt->execute([$rideId, $driverId]);
|
||
|
||
if ($checkStmt->rowCount() > 0) {
|
||
// موجود: تحديث
|
||
$stmtLog = $con->prepare("UPDATE driver_orders SET status = ?, notes = ?, created_at = NOW() WHERE order_id = ? AND driver_id = ?");
|
||
$stmtLog->execute([$statusText, $reason, $rideId, $driverId]);
|
||
} else {
|
||
// غير موجود: إدخال
|
||
$stmtLog = $con->prepare("INSERT INTO driver_orders (driver_id, order_id, status, created_at, notes) VALUES (?, ?, ?, NOW(), ?)");
|
||
$stmtLog->execute([$driverId, $rideId, $statusText, $reason]);
|
||
}
|
||
|
||
// ---------------------------------------------------------
|
||
// 2. منطق الحظر (Business Logic)
|
||
// ---------------------------------------------------------
|
||
$stmtCount = $con->prepare("
|
||
SELECT COUNT(*) FROM driver_orders
|
||
WHERE driver_id = ?
|
||
AND status = ?
|
||
AND created_at >= NOW() - INTERVAL 1 DAY
|
||
");
|
||
$stmtCount->execute([$driverId, $statusText]);
|
||
$cancelCount = $stmtCount->fetchColumn();
|
||
|
||
$isBlocked = false;
|
||
$blockUntil = "";
|
||
|
||
if ($cancelCount >= 3) {
|
||
$isBlocked = true;
|
||
$blockUntil = date('Y-m-d H:i:s', strtotime('+4 hours'));
|
||
|
||
// تثبيت الإيقاف على الخادم. كان يُكتب في تخزين تطبيق السائق وحده
|
||
// (statusDriverLocation='blocked')، فيُفكّ بمسح البيانات أو إعادة
|
||
// التثبيت. الآن 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] تعذّر تسجيل الإيقاف في MySQL (شغّل"
|
||
. " migrations/2026_08_07_driver_cancel_compensation.sql): " . $eBlock->getMessage());
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------
|
||
// 3. تحديث حالة الرحلة في جدول ride
|
||
// ---------------------------------------------------------
|
||
// التوثيق النهائي: بيانات التعويض التي عاشت في 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
|
||
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)) {
|
||
try {
|
||
$con_ride->prepare($sqlRide)->execute($rideParams);
|
||
} catch (PDOException $eCols) {
|
||
$con_ride->prepare($sqlRideLegacy)->execute([$statusText, $rideId]);
|
||
}
|
||
}
|
||
|
||
// 🆕 تصفير التتابع لأن السائق ألغى الرحلة
|
||
handleDriverStreak($con, $driverId, 'reset');
|
||
|
||
// ---------------------------------------------------------
|
||
// 4. إشعار الراكب
|
||
// ---------------------------------------------------------
|
||
|
||
// أ) Socket (يحتاج Passenger ID)
|
||
$stmtPas = $con->prepare("SELECT passenger_id FROM ride WHERE id = ?");
|
||
$stmtPas->execute([$rideId]);
|
||
$passenger_id = $stmtPas->fetchColumn();
|
||
|
||
if ($passenger_id) {
|
||
$socketPayload = [
|
||
'ride_id' => $rideId,
|
||
'status' => 'cancelled_by_driver',
|
||
'msg' => 'تم إلغاء الرحلة من قبل السائق'
|
||
];
|
||
if (function_exists('notifyPassengerOnRideServer')) {
|
||
notifyPassengerOnRideServer($passenger_id, $socketPayload);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 4.1. تعويض السائق عن عدم حضور الراكب
|
||
// = مسافة الاقتراب × سعر الكيلومتر لنوع السيارة
|
||
// + دقائق الانتظار × سعر الدقيقة حسب وقت اليوم
|
||
// كل المدخلات مسجَّلة على الخادم؛ لا شيء منها من العميل.
|
||
// ─────────────────────────────────────────────────────────
|
||
// أسعار دولة هذه الرحلة تحديداً. كان "LIMIT 1" يعيد أول صف
|
||
// (سوريا) فيطبّق 40 SYP/كم على رحلة أردنية سعرها 0.284 JOD/كم.
|
||
// $driverId يُمرَّر صراحةً: القسم ٣ صفّر ride.driver_id قبل هذه
|
||
// النقطة، فالبحث عبر الرحلة كان سيرجع بلا دولة ويقع على سوريا.
|
||
$kazan = getKazanForRide($con, $rideId, 'Syria', $driverId);
|
||
$country = $kazan['country'] ?? 'Jordan';
|
||
|
||
$compensationCap = (float) ($kazan['cancelCompensationCap'] ?? 0);
|
||
$minWaitMinutes = (int) ($kazan['cancelMinWaitMinutes'] ?? 5);
|
||
|
||
// $approachKm و$arrivedTs قُرئا من Redis قراءةً واحدة في القسم صفر.
|
||
$compensation = 0.0;
|
||
$waitedMinutes = 0;
|
||
|
||
if ($compensationCap <= 0) {
|
||
// التعويض معطّل لهذه الدولة — الافتراضي حتى تضبطه الإدارة.
|
||
error_log("[cancel_ride_by_driver] التعويض معطّل (cancelCompensationCap=0)"
|
||
. " ride=$rideId");
|
||
} elseif ($arrivedTs <= 0) {
|
||
// لا ختم وصول ⇒ السائق ألغى قبل أن يصل، أو تعذّر إثبات وصوله.
|
||
error_log("[cancel_ride_by_driver] إلغاء بلا ختم وصول — بلا تعويض"
|
||
. " ride=$rideId driver=$driverId");
|
||
} else {
|
||
$waitedMinutes = (int) floor((time() - $arrivedTs) / 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 ($compensation > 0) {
|
||
// يُخصم من الراكب كدين سالب، ويُضاف للسائق كمكافأة — الطرفان معاً.
|
||
$negativeDebt = -$compensation;
|
||
|
||
$walletServer = "https://walletintaleq.intaleq.xyz";
|
||
if (strtolower($country) == 'jordan') {
|
||
$walletServer = getenv('WALLET_SERVER_JORDAN') ?: "https://walletintaleq.intaleq.xyz";
|
||
} elseif (strtolower($country) == 'egypt') {
|
||
$walletServer = getenv('WALLET_SERVER_EGYPT') ?: "https://walletintaleq.intaleq.xyz";
|
||
} else {
|
||
$walletServer = getenv('WALLET_SERVER_SYRIA') ?: "https://walletintaleq.intaleq.xyz";
|
||
}
|
||
|
||
// S2S call to add debt to passenger wallet on the payment server
|
||
$walletUrl = "$walletServer/v2/main/ride/passengerWallet/add_s2s_debt.php";
|
||
$ch = curl_init($walletUrl);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => http_build_query([
|
||
"passengerID" => $passenger_id,
|
||
"amount" => $negativeDebt
|
||
]),
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 5,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/x-www-form-urlencoded',
|
||
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY')
|
||
]
|
||
]);
|
||
$s2sRes = curl_exec($ch);
|
||
$s2sCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
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;
|
||
if (isset($redis) && $redis !== null) {
|
||
$redisInstance = $redis;
|
||
} else if (extension_loaded('redis')) {
|
||
$localRedis = new Redis();
|
||
$redisHost = getenv('REDIS_MAIN_HOST') ?: getenv('REDIS_HOST') ?: '127.0.0.1';
|
||
$redisPort = (int)(getenv('REDIS_MAIN_PORT') ?: getenv('REDIS_PORT') ?: 6379);
|
||
$redisPass = getenv('REDIS_MAIN_PASSWORD') ?: getenv('REDIS_MAIN_AUTH') ?: getenv('REDIS_PASSWORD') ?: getenv('REDIS_AUTH');
|
||
if ($localRedis->connect($redisHost, $redisPort, 1.5)) {
|
||
if ($redisPass) $localRedis->auth($redisPass);
|
||
$localRedis->setOption(Redis::OPT_PREFIX, 'siro:');
|
||
$redisInstance = $localRedis;
|
||
}
|
||
}
|
||
|
||
if ($redisInstance !== null) {
|
||
$redisKey = "passenger_debt_" . $passenger_id;
|
||
// إضافة الدين الجديد إلى الدين السابق إن وجد
|
||
$currentDebt = (float) $redisInstance->get($redisKey);
|
||
$newDebt = $currentDebt + $negativeDebt;
|
||
$redisInstance->setex($redisKey, 15552000, $newDebt);
|
||
}
|
||
} catch (Exception $e) {
|
||
error_log("Redis Error in cancel_ride_by_driver: " . $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
// ب) FCM (Internal)
|
||
if (empty($passengerToken) && $passenger_id) {
|
||
$stmtToken = $con->prepare("SELECT token FROM tokens WHERE passengerID = ? ORDER BY id DESC LIMIT 1");
|
||
$stmtToken->execute([$passenger_id]);
|
||
$rawToken = $stmtToken->fetchColumn();
|
||
if ($rawToken) {
|
||
$passengerToken = $rawToken;
|
||
if (!empty($encryptionHelper)) {
|
||
try {
|
||
$decrypted = $encryptionHelper->decryptData($rawToken);
|
||
if ($decrypted !== false && !empty($decrypted)) {
|
||
$passengerToken = trim($decrypted);
|
||
}
|
||
} catch (Exception $e) {
|
||
// Fallback
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!empty($passengerToken)) {
|
||
$fcmData = [
|
||
'category' => 'Cancel Trip from driver',
|
||
'ride_id' => (string)$rideId
|
||
];
|
||
|
||
// 🔥 استخدام الدالة الجديدة
|
||
sendFCM_Internal(
|
||
$passengerToken, // الهدف
|
||
"تم إلغاء الرحلة ❌", // العنوان
|
||
"عذراً، قام السائق بإلغاء الرحلة.", // النص
|
||
$fcmData, // البيانات
|
||
"Cancel Trip from driver", // التصنيف (تأكد أنه يطابق ما في تطبيق الراكب)
|
||
false // ليس Topic
|
||
);
|
||
}
|
||
|
||
$con->commit();
|
||
|
||
// 🆕 Cache ride state in Redis — use $statusText (the exact value just
|
||
// written to MySQL above), not a hardcoded label, so a cache hit can
|
||
// never disagree with the row it was seeded from.
|
||
sendToLocationServer('update_ride_state', [
|
||
'ride_id' => $rideId,
|
||
'status' => $statusText,
|
||
'driver_id' => $driverId,
|
||
'passenger_id' => $passenger_id ?? '',
|
||
]);
|
||
|
||
// 5. الرد للفلاتر
|
||
echo json_encode([
|
||
"status" => "success",
|
||
"cancel_count" => $cancelCount,
|
||
"is_blocked" => $isBlocked,
|
||
"block_until" => $blockUntil,
|
||
// ما استحقه السائق فعلاً عن هذه الرحلة — يعرضه التطبيق كتعويض،
|
||
// لا كغرامة. صفر يعني أنه ألغى قبل الوصول أو قبل مدة الانتظار.
|
||
"compensation" => $compensation ?? 0,
|
||
"waited_minutes" => $waitedMinutes ?? 0,
|
||
]);
|
||
|
||
} catch (PDOException $e) {
|
||
if ($con->inTransaction()) $con->rollBack();
|
||
error_log("[cancel_ride_by_driver] " . $e->getMessage());
|
||
jsonError("DB Error");
|
||
}
|
||
?>
|