Files
Siro/backend/ride/rides/cancel_ride_by_driver.php
T

438 lines
22 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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_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);
// ‏سقف صلب بعملة الدولة لا رقماً مجرّداً: 50000 كانت
// ‏بلا أثر على الليرة بعد حذف صفرين، وكارثية على الدينار.
$currencyCode = (string) ($kazan['currency'] ?? 'SYP');
$compensation = round(min($compensation, $compensationCap,
getMoneyHardCap($currencyCode)), 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");
}
?>