Update: 2026-08-07 02:19:07
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
-- 2026_08_07_passenger_cancel_policy.sql
|
||||
--
|
||||
-- رسوم إلغاء الراكب بعد قبول السائق.
|
||||
--
|
||||
-- متماثلة مع تعويض عدم الحضور (ترحيل 2026_08_07_driver_cancel_compensation):
|
||||
-- نفس المبدأ — من تسبب بضياع وقت الآخر يعوّضه، والمال يذهب **للسائق** لا
|
||||
-- للشركة. الفرق أن الزمن المحتسب هنا يبدأ من لحظة القبول لا من لحظة الوصول،
|
||||
-- لأن السائق يكون في طريقه لا واقفاً ينتظر.
|
||||
--
|
||||
-- ثلاثة إعفاءات مبنية عمداً، لأن رسماً بلا إعفاء يعاقب الراكب على أخطاء
|
||||
-- ليست خطأه ويطرده من التطبيق:
|
||||
-- ١. لا سائق قُبِل بعد → مجاني دائماً
|
||||
-- ٢. خلال أول دقائق من القبول → مجاني (passengerCancelFreeMinutes)
|
||||
-- ٣. السائق تأخّر ولم يصل → مجاني (driverLateExemptMinutes)
|
||||
--
|
||||
-- ⚠️ يُنفَّذ على قاعدة primary (jorSiroDB) وحدها — kazan يُقرأ عبر $con.
|
||||
|
||||
ALTER TABLE `kazan`
|
||||
ADD COLUMN `passengerCancelCap` VARCHAR(10)
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci
|
||||
NOT NULL DEFAULT '0'
|
||||
COMMENT 'سقف رسم إلغاء الراكب بعملة الدولة. 0 = الرسم معطّل بالكامل'
|
||||
AFTER `cancelMinWaitMinutes`,
|
||||
ADD COLUMN `passengerCancelFreeMinutes` TINYINT UNSIGNED
|
||||
NOT NULL DEFAULT 2
|
||||
COMMENT 'دقائق الإلغاء المجاني بعد قبول السائق'
|
||||
AFTER `passengerCancelCap`,
|
||||
ADD COLUMN `driverLateExemptMinutes` TINYINT UNSIGNED
|
||||
NOT NULL DEFAULT 10
|
||||
COMMENT 'إن مرّت هذه الدقائق على القبول ولم يصل السائق، الإلغاء مجاني';
|
||||
|
||||
-- عدّاد عدم الحضور للراكب — سجل دائم للمراجعة الإدارية.
|
||||
-- (الفحص السريع يتم من Redis، وهذا العمود للتقارير وقرارات الحظر اليدوية.)
|
||||
ALTER TABLE `passengers`
|
||||
ADD COLUMN `noShowCount` SMALLINT UNSIGNED NOT NULL DEFAULT 0
|
||||
COMMENT 'مرات إلغاء الراكب بعد قبول السائق ودفعه رسماً';
|
||||
|
||||
-- للتراجع:
|
||||
-- ALTER TABLE `kazan` DROP COLUMN `passengerCancelCap`,
|
||||
-- DROP COLUMN `passengerCancelFreeMinutes`, DROP COLUMN `driverLateExemptMinutes`;
|
||||
-- ALTER TABLE `passengers` DROP COLUMN `noShowCount`;
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// cancel_fee_helper.php — رسم إلغاء الراكب بعد قبول السائق
|
||||
//
|
||||
// المبدأ نفسه المطبَّق في تعويض عدم الحضور: من ضيّع وقت الآخر يعوّضه،
|
||||
// والمال يذهب للسائق لا للشركة. الفرق الوحيد أن الزمن هنا يُحتسب من
|
||||
// لحظة القبول — السائق كان يقود نحو الراكب لا واقفاً ينتظره.
|
||||
//
|
||||
// لا شيء من المبلغ يأتي من العميل. كل المدخلات من الخادم:
|
||||
// • مسافة الاقتراب ← Redis (سجّلها acceptRide.php لحظة القبول)
|
||||
// • زمن الانتظار ← ride.rideTimeStart (يكتبه acceptRide.php)
|
||||
// • الأسعار ← جدول kazan عبر pricing_helper.php
|
||||
// ============================================================
|
||||
|
||||
// سقف صلب فوق أي قيمة في القاعدة — يمنع خطأً إدارياً من خصم كارثي.
|
||||
const PASSENGER_CANCEL_ABSOLUTE_CAP = 50000.0;
|
||||
|
||||
// حد أقصى للدقائق المحتسبة: سائق قَبِل ونسي الرحلة ساعة لا يتحول
|
||||
// نسيانه إلى فاتورة على الراكب.
|
||||
const PASSENGER_CANCEL_MAX_BILLABLE_MINUTES = 20;
|
||||
|
||||
/**
|
||||
* يحسب رسم الإلغاء، أو صفراً مع سبب الإعفاء.
|
||||
*
|
||||
* @param string $exemptReason يُملأ بسبب الإعفاء إن كان الرسم صفراً (بالمرجع)
|
||||
* @return float المبلغ المستحق للسائق
|
||||
*/
|
||||
function computePassengerCancelFee(
|
||||
PDO $con,
|
||||
$redisLocation,
|
||||
$rideId,
|
||||
array $ride,
|
||||
$driverId,
|
||||
string &$exemptReason
|
||||
): float {
|
||||
// ── إعفاء ١: لا سائق قَبِل بعد ──────────────────────────
|
||||
// لا أحد تحرّك، فلا أحد يستحق تعويضاً. وهذه أغلب حالات الإلغاء.
|
||||
if (empty($driverId) || (string) $driverId === '0') {
|
||||
$exemptReason = 'no_driver_assigned';
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// ── سياسة الدولة ────────────────────────────────────────
|
||||
$stmtKazan = $con->prepare("SELECT * FROM kazan LIMIT 1");
|
||||
$stmtKazan->execute();
|
||||
$kazan = $stmtKazan->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
$cap = (float) ($kazan['passengerCancelCap'] ?? 0);
|
||||
if ($cap <= 0) {
|
||||
// الرسم معطّل لهذه الدولة — الافتراضي حتى تضبطه الإدارة.
|
||||
$exemptReason = 'policy_disabled';
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$freeMinutes = (int) ($kazan['passengerCancelFreeMinutes'] ?? 2);
|
||||
$lateExemptMinutes = (int) ($kazan['driverLateExemptMinutes'] ?? 10);
|
||||
|
||||
// ── الزمن منذ القبول ────────────────────────────────────
|
||||
// acceptRide.php يكتب rideTimeStart = NOW() لحظة فوز السائق بالقفل.
|
||||
$acceptedAt = !empty($ride['rideTimeStart']) ? strtotime($ride['rideTimeStart']) : 0;
|
||||
if ($acceptedAt <= 0) {
|
||||
$exemptReason = 'accept_time_unknown';
|
||||
return 0.0;
|
||||
}
|
||||
$minutesSinceAccept = (int) floor((time() - $acceptedAt) / 60);
|
||||
|
||||
// ── إعفاء ٢: نافذة الإلغاء المجاني ──────────────────────
|
||||
// الراكب يغيّر رأيه أو يخطئ الوجهة — دقيقتان لتصحيح ذلك بلا عقاب.
|
||||
if ($minutesSinceAccept < $freeMinutes) {
|
||||
$exemptReason = 'within_free_window';
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// ── حالة الوصول من Redis ────────────────────────────────
|
||||
$approachKm = 0.0;
|
||||
$arrivedTs = 0;
|
||||
if ($redisLocation) {
|
||||
try {
|
||||
$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 $e) {
|
||||
error_log("[cancel_fee] تعذّرت قراءة حالة الرحلة: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── إعفاء ٣: السائق تأخّر ولم يصل ───────────────────────
|
||||
// أهم الإعفاءات الثلاثة: بدونه نعاقب الراكب على بطء السائق، وهذا
|
||||
// يطرد الركاب أسرع مما يوفّره الرسم.
|
||||
if ($arrivedTs <= 0 && $minutesSinceAccept >= $lateExemptMinutes) {
|
||||
$exemptReason = 'driver_late';
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// ── الحساب ──────────────────────────────────────────────
|
||||
$billableMinutes = min($minutesSinceAccept, PASSENGER_CANCEL_MAX_BILLABLE_MINUTES);
|
||||
$carType = (string) ($ride['carType'] ?? $ride['car_type'] ?? 'Speed');
|
||||
|
||||
$fee = ($approachKm * getPerKmRate($carType, $kazan))
|
||||
+ ($billableMinutes * getPerMinRate($kazan));
|
||||
|
||||
$fee = round(min($fee, $cap, PASSENGER_CANCEL_ABSOLUTE_CAP), 2);
|
||||
|
||||
error_log("[cancel_fee] ride=$rideId رسم إلغاء: {$approachKm}كم"
|
||||
. " + {$billableMinutes}د = $fee (سقف $cap)");
|
||||
|
||||
return max(0.0, $fee);
|
||||
}
|
||||
|
||||
/**
|
||||
* ينفّذ التحويل: خصم من الراكب وإضافة للسائق.
|
||||
*
|
||||
* يُستدعى بعد الـcommit — نداءات S2S شبكية لا تُترك داخل معاملة مفتوحة.
|
||||
* لا يرمي أبداً: فشل التحويل يُسجَّل للتسوية ولا يُبطل إلغاءً تمّ فعلاً.
|
||||
*/
|
||||
function applyCancelFeeTransfer(PDO $con, $passengerId, $driverId, $rideId, float $fee): void
|
||||
{
|
||||
if ($fee <= 0 || empty($passengerId) || empty($driverId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmtKazan = $con->prepare("SELECT country FROM kazan LIMIT 1");
|
||||
$stmtKazan->execute();
|
||||
$country = strtolower((string) ($stmtKazan->fetchColumn() ?: 'jordan'));
|
||||
} catch (PDOException $e) {
|
||||
$country = 'jordan';
|
||||
}
|
||||
|
||||
$walletServer = "https://walletintaleq.intaleq.xyz";
|
||||
if ($country === 'jordan') {
|
||||
$walletServer = getenv('WALLET_SERVER_JORDAN') ?: $walletServer;
|
||||
} elseif ($country === 'egypt') {
|
||||
$walletServer = getenv('WALLET_SERVER_EGYPT') ?: $walletServer;
|
||||
} else {
|
||||
$walletServer = getenv('WALLET_SERVER_SYRIA') ?: $walletServer;
|
||||
}
|
||||
|
||||
$s2s = function (string $url, array $fields): int {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($fields),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
|
||||
],
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
return (int) $code;
|
||||
};
|
||||
|
||||
// خصم من الراكب (دين سالب)
|
||||
$debtCode = $s2s(
|
||||
"$walletServer/v2/main/ride/passengerWallet/add_s2s_debt.php",
|
||||
['passengerID' => $passengerId, 'amount' => -$fee]
|
||||
);
|
||||
|
||||
// إضافة للسائق — paymentID مشتق من الرحلة فيمنع ازدواج الصرف
|
||||
$rewardCode = $s2s(
|
||||
"$walletServer/v2/main/ride/driverWallet/add_s2s_reward.php",
|
||||
[
|
||||
'driverID' => $driverId,
|
||||
'paymentID' => "pax_cancel_$rideId",
|
||||
'amount' => $fee,
|
||||
'paymentMethod' => 'passenger_cancel_compensation',
|
||||
]
|
||||
);
|
||||
|
||||
if ($debtCode !== 200 || $rewardCode !== 200) {
|
||||
// أي طرف فشل يعني اختلالاً يجب أن يظهر في التسوية، لا أن يمرّ صامتاً.
|
||||
error_log("[cancel_fee] MONEY: رسم $fee للرحلة $rideId —"
|
||||
. " خصم الراكب=$debtCode، رصيد السائق=$rewardCode"
|
||||
. " (راكب=$passengerId سائق=$driverId)");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
require_once __DIR__ . '/../pricing/pricing_helper.php';
|
||||
require_once __DIR__ . '/cancel_fee_helper.php';
|
||||
|
||||
try {
|
||||
$con_ride = Database::get('ride');
|
||||
@@ -18,8 +20,10 @@ if (!$rideId) {
|
||||
}
|
||||
|
||||
try {
|
||||
// جلب بيانات الرحلة للتحقق (مع passenger_id للإدراج في canecl)
|
||||
$stmt = $con->prepare("SELECT driver_id, passenger_id, status FROM ride WHERE id = ?");
|
||||
// SELECT * لا قائمة أعمدة: نحتاج rideTimeStart و carType لحساب الرسم،
|
||||
// وأسماء بعض الأعمدة متضاربة بين الschema والقاعدة الحية (carType /
|
||||
// car_type). النجمة تجعل الاستعلام يعمل في كل الحالات.
|
||||
$stmt = $con->prepare("SELECT * FROM ride WHERE id = ?");
|
||||
$stmt->execute([$rideId]);
|
||||
$ride = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
@@ -64,8 +68,47 @@ try {
|
||||
$insertCanecl->execute([$driverId, $passengerId, $rideId, $reason]);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// 1.5 رسم الإلغاء — تعويض للسائق لا إيراد للشركة
|
||||
//
|
||||
// متماثل مع تعويض عدم الحضور في cancel_ride_by_driver.php: من ضيّع
|
||||
// وقت الآخر يعوّضه. الفرق أن الزمن يُحتسب من لحظة القبول لا من لحظة
|
||||
// الوصول — السائق كان في طريقه لا واقفاً ينتظر.
|
||||
//
|
||||
// المبلغ لا يأتي من العميل بأي حال، ويُحسب من أسعار kazan نفسها.
|
||||
// =================================================================
|
||||
$cancelFee = 0.0;
|
||||
$feeExemptReason = '';
|
||||
try {
|
||||
$cancelFee = computePassengerCancelFee(
|
||||
$con, $redisLocation ?? null, $rideId, $ride, $driverId, $feeExemptReason
|
||||
);
|
||||
} catch (Throwable $eFee) {
|
||||
// فشل حساب الرسم لا يمنع الإلغاء أبداً — الإلغاء حق للراكب.
|
||||
error_log("[cancel_ride_by_passenger] تعذّر حساب الرسم: " . $eFee->getMessage());
|
||||
}
|
||||
|
||||
if ($cancelFee > 0) {
|
||||
// عدّاد عدم الحضور — للتقارير وقرارات الحظر اليدوية.
|
||||
try {
|
||||
$con->prepare("UPDATE passengers SET noShowCount = noShowCount + 1 WHERE id = ?")
|
||||
->execute([$ride['passenger_id'] ?? '']);
|
||||
} catch (PDOException $eCnt) {
|
||||
error_log("[cancel_ride_by_passenger] عدّاد عدم الحضور غير مُرحَّل بعد: "
|
||||
. $eCnt->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$con->commit();
|
||||
|
||||
// التحويل المالي بعد الـcommit لا داخله: نداءات S2S شبكية قد تستغرق
|
||||
// ثوانٍ، وإبقاء المعاملة مفتوحة خلالها يقفل صفوف ride تحت ضغط.
|
||||
if ($cancelFee > 0) {
|
||||
applyCancelFeeTransfer(
|
||||
$con, $ride['passenger_id'] ?? '', $driverId, $rideId, $cancelFee
|
||||
);
|
||||
}
|
||||
|
||||
// تحديث السيرفر البعيد (Remote DB)
|
||||
if (isset($con_ride)) {
|
||||
try {
|
||||
@@ -155,7 +198,12 @@ try {
|
||||
'passenger_id' => $ride['passenger_id'] ?? '',
|
||||
]);
|
||||
|
||||
jsonSuccess(null, "Ride cancelled successfully");
|
||||
// الرسم يُرجَع للتطبيق ليعرضه صراحةً. خصم صامت من المحفظة يكتشفه
|
||||
// الراكب لاحقاً هو أسرع طريق لشكوى ولمراجعة سيئة.
|
||||
jsonSuccess([
|
||||
'cancel_fee' => $cancelFee ?? 0,
|
||||
'fee_exempt' => ($cancelFee ?? 0) > 0 ? '' : ($feeExemptReason ?? ''),
|
||||
], "Ride cancelled successfully");
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
|
||||
Reference in New Issue
Block a user