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
|
<?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 {
|
try {
|
||||||
$con_ride = Database::get('ride');
|
$con_ride = Database::get('ride');
|
||||||
@@ -18,8 +20,10 @@ if (!$rideId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// جلب بيانات الرحلة للتحقق (مع passenger_id للإدراج في canecl)
|
// SELECT * لا قائمة أعمدة: نحتاج rideTimeStart و carType لحساب الرسم،
|
||||||
$stmt = $con->prepare("SELECT driver_id, passenger_id, status FROM ride WHERE id = ?");
|
// وأسماء بعض الأعمدة متضاربة بين الschema والقاعدة الحية (carType /
|
||||||
|
// car_type). النجمة تجعل الاستعلام يعمل في كل الحالات.
|
||||||
|
$stmt = $con->prepare("SELECT * FROM ride WHERE id = ?");
|
||||||
$stmt->execute([$rideId]);
|
$stmt->execute([$rideId]);
|
||||||
$ride = $stmt->fetch(PDO::FETCH_ASSOC);
|
$ride = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
@@ -64,8 +68,47 @@ try {
|
|||||||
$insertCanecl->execute([$driverId, $passengerId, $rideId, $reason]);
|
$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();
|
$con->commit();
|
||||||
|
|
||||||
|
// التحويل المالي بعد الـcommit لا داخله: نداءات S2S شبكية قد تستغرق
|
||||||
|
// ثوانٍ، وإبقاء المعاملة مفتوحة خلالها يقفل صفوف ride تحت ضغط.
|
||||||
|
if ($cancelFee > 0) {
|
||||||
|
applyCancelFeeTransfer(
|
||||||
|
$con, $ride['passenger_id'] ?? '', $driverId, $rideId, $cancelFee
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// تحديث السيرفر البعيد (Remote DB)
|
// تحديث السيرفر البعيد (Remote DB)
|
||||||
if (isset($con_ride)) {
|
if (isset($con_ride)) {
|
||||||
try {
|
try {
|
||||||
@@ -155,7 +198,12 @@ try {
|
|||||||
'passenger_id' => $ride['passenger_id'] ?? '',
|
'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) {
|
} catch (PDOException $e) {
|
||||||
if ($con->inTransaction()) $con->rollBack();
|
if ($con->inTransaction()) $con->rollBack();
|
||||||
|
|||||||
Reference in New Issue
Block a user