Update: 2026-08-07 02:19:07

This commit is contained in:
Hamza-Ayed
2026-08-07 02:19:07 +03:00
parent b492034bad
commit 3081aea802
3 changed files with 274 additions and 4 deletions
@@ -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`;
+181
View File
@@ -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();