Update: 2026-08-07 05:56:50
This commit is contained in:
@@ -261,3 +261,33 @@ function getMoneyHardCap(string $currency): float
|
||||
// والصرف الخاطئ لا يُسترد.
|
||||
return $caps[strtoupper($currency)] ?? min($caps);
|
||||
}
|
||||
|
||||
/**
|
||||
* تسعير داخلي للرحلات التي تُنشأ خادمياً (الحجز المسبق).
|
||||
*
|
||||
* ⚠️ ليس بديلاً عن ride/pricing/get.php — ذاك محرك كامل فيه الذروة
|
||||
* وإضافات المطار والخصومات المناطقية، ويُستدعى من التطبيق مع رمز سعر
|
||||
* موقَّع. هذه دالة أبسط لمسار واحد لا يملك رمزاً ولا جلسة راكب.
|
||||
*
|
||||
* المكوّنات هي نفسها التي تُسوَّى بها الرحلة في finish_ride_updates.php:
|
||||
* فتحة العداد + المسافة × سعر الكيلومتر + الزمن × سعر الدقيقة
|
||||
*
|
||||
* فالنتيجة متسقة مع ما سيُحاسَب عليه الراكب فعلاً عند الإنهاء، وأي فرق
|
||||
* يبقى في صالحه لا ضده (لا تُحتسب إضافات الذروة هنا).
|
||||
*/
|
||||
function computeInternalRidePrice(array $kazan, string $carType,
|
||||
float $distanceKm, int $durationSeconds): float
|
||||
{
|
||||
$startPrice = (float) ($kazan['startPrice'] ?? 0);
|
||||
$perKm = getPerKmRate($carType, $kazan);
|
||||
$perMin = getPerMinRate($kazan);
|
||||
|
||||
// حد أدنى للمسافة المحتسبة — يمنع رحلة بمسافة صفر من إنتاج سعر صفر
|
||||
// إن وصلت بيانات ناقصة.
|
||||
$km = max(0.2, $distanceKm);
|
||||
$minutes = max(1.0, $durationSeconds / 60.0);
|
||||
|
||||
$price = $startPrice + ($km * $perKm) + ($minutes * $perMin);
|
||||
|
||||
return round(max(0.0, $price), 2);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ function buildMarketPayload($rideId, $lat, $lng, $payloadData, $extraMarketData
|
||||
'duration' => $payloadData[15],
|
||||
'passengerRate' => $payloadData[33],
|
||||
'passengerId' => $payloadData[7],
|
||||
// رحلة محجوزة: يقرأها available_rides_page و order_request_page
|
||||
// ونافذة الأوفرلي. غيابها = رحلة لحظية عادية.
|
||||
'is_scheduled' => $payloadData[39] ?? '',
|
||||
'scheduled_at' => $payloadData[40] ?? '',
|
||||
], $extraMarketData);
|
||||
}
|
||||
|
||||
@@ -113,13 +117,89 @@ function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketDa
|
||||
error_log("[add_ride] Request started. passenger_id=" . ($_POST['passenger_id'] ?? '?'));
|
||||
|
||||
// ── 1. Input ───────────────────────────────────────────────────
|
||||
// وسم الحجز المسبق: يمرّره bot/cron_scheduled_rides.php حين يحوّل حجزاً
|
||||
// لرحلة فعلية. الرحلة العادية تتركه فارغاً فلا يظهر شيء في التطبيقات.
|
||||
$isScheduledRide = filterRequest("source") === 'scheduled';
|
||||
$scheduledAtLabel = filterRequest("scheduled_at") ?: '';
|
||||
|
||||
$start_location = filterRequest("start_location");
|
||||
$end_location = filterRequest("end_location");
|
||||
$price = filterRequest("price");
|
||||
$price_token = filterRequest("price_token");
|
||||
|
||||
// Force passenger_id from JWT — never trust user-supplied passenger_id
|
||||
$passenger_id = $user_id;
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// 🔐 مسار S2S للحجز المسبق — بوّابة مزدوجة لا مفتاح وحده
|
||||
//
|
||||
// bot/cron_scheduled_rides.php لا يملك JWT راكب ولا رمز سعر، فلا يمكنه
|
||||
// المرور من الحراسة العادية. لكن فتح استثناء بمفتاح S2S وحده يعني أن
|
||||
// تسريب المفتاح = إنشاء رحلات باسم أي راكب.
|
||||
//
|
||||
// لذلك شرطان معاً:
|
||||
// ١. مفتاح S2S صحيح
|
||||
// ٢. الحجز موجود فعلاً في scheduled_rides بحالة 'dispatching'، ويخصّ
|
||||
// هذا الراكب بالذات، وبنفس نقطة الانطلاق
|
||||
//
|
||||
// الشرط الثاني هو الحماية الحقيقية: لا رحلة تُنشأ إلا مقابل حجز أنشأه
|
||||
// الراكب بنفسه عبر المسار الموثَّق، والكرون قفله للتوّ. حامل المفتاح
|
||||
// وحده لا يستطيع اختلاق حجز.
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
$isInternalScheduled = false;
|
||||
|
||||
if ($isScheduledRide) {
|
||||
$providedKey = $_SERVER['HTTP_X_S2S_API_KEY'] ?? '';
|
||||
$expectedKey = getenv('S2S_SHARED_KEY') ?: '';
|
||||
$scheduledId = (int) (filterRequest('scheduled_id', 'int') ?: 0);
|
||||
$claimedPassenger = filterRequest('passenger_id');
|
||||
|
||||
if (empty($expectedKey) || !hash_equals($expectedKey, (string) $providedKey)) {
|
||||
error_log('[add_ride] SECURITY: مسار الحجز بمفتاح S2S غير صالح');
|
||||
printFailure('Unauthorized');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($scheduledId <= 0 || empty($claimedPassenger)) {
|
||||
printFailure('Missing scheduled_id or passenger_id');
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmtBooking = $con->prepare("
|
||||
SELECT passenger_id, start_location, end_location, car_type,
|
||||
distance, duration
|
||||
FROM scheduled_rides
|
||||
WHERE id = ? AND status = 'dispatching' LIMIT 1
|
||||
");
|
||||
$stmtBooking->execute([$scheduledId]);
|
||||
$booking = $stmtBooking->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $eB) {
|
||||
error_log('[add_ride] تعذّرت قراءة الحجز: ' . $eB->getMessage());
|
||||
printFailure('Server error');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$booking) {
|
||||
error_log("[add_ride] SECURITY: حجز #$scheduledId غير موجود أو ليس قيد الإطلاق");
|
||||
printFailure('Invalid booking');
|
||||
exit;
|
||||
}
|
||||
|
||||
if ((string) $booking['passenger_id'] !== (string) $claimedPassenger
|
||||
|| !coordsMatch($booking['start_location'], $start_location)) {
|
||||
error_log("[add_ride] SECURITY: حجز #$scheduledId لا يطابق الراكب أو النقطة");
|
||||
printFailure('Booking mismatch');
|
||||
exit;
|
||||
}
|
||||
|
||||
// من هنا نثق ببيانات **الحجز المخزَّن** لا بما وصل في الطلب.
|
||||
$isInternalScheduled = true;
|
||||
$passenger_id = $booking['passenger_id'];
|
||||
$end_location = $booking['end_location'];
|
||||
$carType = $booking['car_type'];
|
||||
$distance = $booking['distance'];
|
||||
} else {
|
||||
// Force passenger_id from JWT — never trust user-supplied passenger_id
|
||||
$passenger_id = $user_id;
|
||||
}
|
||||
$driver_id = (string)(filterRequest("driver_id") ?: '0');
|
||||
$status = filterRequest("status") ?: 'nothing';
|
||||
$price_for_driver = filterRequest("price_for_driver") ?: ($price ?: '0');
|
||||
@@ -160,34 +240,68 @@ if (empty($passenger_id) || empty($start_location) || empty($end_location) || em
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── التسعير الداخلي لمسار الحجز ─────────────────────────────
|
||||
// السعر لا يأتي من الطلب: الكرون يمرّر تقدير وقت الحجز، وهو قديم بساعات
|
||||
// ولا يعرف الذروة. نحسبه هنا من أسعار الدولة لحظة الإنشاء.
|
||||
if ($isInternalScheduled) {
|
||||
require_once __DIR__ . '/../pricing/pricing_helper.php';
|
||||
|
||||
$kazanRow = [];
|
||||
try {
|
||||
$stmtK = $con->prepare("SELECT * FROM kazan WHERE country = ? LIMIT 1");
|
||||
$stmtK->execute([getenv('GLOBAL_COUNTRY') ?: 'Jordan']);
|
||||
$kazanRow = $stmtK->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
} catch (PDOException $eK) {
|
||||
error_log('[add_ride] تعذّر جلب أسعار الدولة للحجز: ' . $eK->getMessage());
|
||||
}
|
||||
|
||||
if (empty($kazanRow)) {
|
||||
printFailure('Pricing unavailable');
|
||||
exit;
|
||||
}
|
||||
|
||||
$price = computeInternalRidePrice(
|
||||
$kazanRow, (string) $carType,
|
||||
(float) $distance, (int) ($booking['duration'] ?? 0)
|
||||
);
|
||||
$price_for_driver = $price;
|
||||
$price_for_passenger = $price;
|
||||
|
||||
error_log("[add_ride] حجز #$scheduledId سُعِّر داخلياً: $price");
|
||||
}
|
||||
|
||||
// SECURE PRICE TOKEN VERIFICATION
|
||||
if (empty($price_token)) {
|
||||
if (!$isInternalScheduled && empty($price_token)) {
|
||||
error_log("[add_ride] Security failed — price_token is missing.");
|
||||
printFailure("Secure price token is required");
|
||||
exit;
|
||||
}
|
||||
|
||||
$decrypted = isset($encryptionHelper) ? $encryptionHelper->decryptData($price_token) : false;
|
||||
if (!$decrypted) {
|
||||
$decrypted = (!$isInternalScheduled && isset($encryptionHelper))
|
||||
? $encryptionHelper->decryptData($price_token) : false;
|
||||
if (!$isInternalScheduled && !$decrypted) {
|
||||
error_log("[add_ride] Security failed — failed to decrypt price_token.");
|
||||
printFailure("Invalid or tampered price token");
|
||||
exit;
|
||||
}
|
||||
|
||||
$tokenData = json_decode($decrypted, true);
|
||||
if (!$tokenData || !isset($tokenData['expires']) || $tokenData['expires'] < time()) {
|
||||
$tokenData = $decrypted ? json_decode($decrypted, true) : null;
|
||||
if (!$isInternalScheduled
|
||||
&& (!$tokenData || !isset($tokenData['expires']) || $tokenData['expires'] < time())) {
|
||||
error_log("[add_ride] Security failed — token is expired or invalid JSON.");
|
||||
printFailure("Price token has expired, please request estimation again");
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($tokenData['passenger_id'] != $passenger_id) {
|
||||
if (!$isInternalScheduled && $tokenData['passenger_id'] != $passenger_id) {
|
||||
error_log("[add_ride] Security failed — passenger_id mismatch.");
|
||||
printFailure("Tampered price token (passenger mismatch)");
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!coordsMatch($tokenData['start_location'], $start_location) || !coordsMatch($tokenData['end_location'], $end_location)) {
|
||||
if (!$isInternalScheduled
|
||||
&& (!coordsMatch($tokenData['start_location'], $start_location)
|
||||
|| !coordsMatch($tokenData['end_location'], $end_location))) {
|
||||
error_log("[add_ride] Security failed — coordinates mismatch. Token: " . ($tokenData['start_location'] . " / " . $tokenData['end_location']) . " Request: " . ($start_location . " / " . $end_location));
|
||||
printFailure("Tampered price token (route mismatch)");
|
||||
exit;
|
||||
@@ -394,6 +508,13 @@ try {
|
||||
// اللي بتوصلها البيانات كـ List مش كـ Map مسمّى مثل FCM
|
||||
isset($extraDispatchData['driver_earnings_extra']) ? $extraDispatchData['driver_earnings_extra'] : '',
|
||||
isset($extraDispatchData['driver_earnings_currency']) ? $extraDispatchData['driver_earnings_currency'] : '',
|
||||
// 🆕 Index 37/38: وسم العرض الحصري في الإسناد بالدفعات
|
||||
'',
|
||||
'',
|
||||
// 🆕 Index 39/40: رحلة محجوزة مسبقاً — السائق يجب أن يعرف أنها
|
||||
// ليست طلباً لحظياً بل موعد مضبوط، وأن الراكب ينتظره في وقت محدد.
|
||||
$isScheduledRide ? '1' : '',
|
||||
$isScheduledRide ? (string) $scheduledAtLabel : '',
|
||||
];
|
||||
|
||||
// Direct dispatch للسائقين القريبين
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// ride/scheduled/add.php — إنشاء حجز مسبق
|
||||
//
|
||||
// لا يُنشئ رحلة. يسجّل نيّة سفر في وقت محدد، ويتولّى الكرون
|
||||
// (bot/cron_scheduled_rides.php) تحويلها لرحلة فعلية قبل الموعد بهامش.
|
||||
//
|
||||
// الفصل مقصود: رحلة تُنشأ الآن لموعد بعد ست ساعات ستدور في السوق ست
|
||||
// ساعات، وتُربك الإسناد والتسعير والخريطة الحرارية.
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// الهوية من الـJWT لا من الطلب.
|
||||
$passengerId = $user_id ?? '';
|
||||
if (empty($passengerId)) {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$startLocation = filterRequest('start_location');
|
||||
$endLocation = filterRequest('end_location');
|
||||
$startName = filterRequest('start_name');
|
||||
$endName = filterRequest('end_name');
|
||||
$carType = filterRequest('car_type') ?: 'Speed';
|
||||
$scheduledAt = filterRequest('scheduled_at'); // 'YYYY-MM-DD HH:MM:SS'
|
||||
$distance = (float) (filterRequest('distance', 'float') ?: 0);
|
||||
$duration = (int) (filterRequest('duration', 'int') ?: 0);
|
||||
$estimated = (float) (filterRequest('estimated_price', 'float') ?: 0);
|
||||
$note = filterRequest('note');
|
||||
|
||||
if (!$startLocation || !$endLocation || !$scheduledAt) {
|
||||
jsonError('start_location, end_location and scheduled_at are required');
|
||||
}
|
||||
|
||||
// قائمة بيضاء لنوع السيارة — نفس منطق findBestDrivers.
|
||||
$allowedCarTypes = ['Comfort', 'Mishwar Vip', 'Scooter', 'Pink Bike', 'Electric',
|
||||
'Lady', 'Van', 'Awfar Car', 'Fixed Price', 'Speed', 'Rayeh Gai'];
|
||||
if (!in_array($carType, $allowedCarTypes, true)) {
|
||||
$carType = 'Speed';
|
||||
}
|
||||
|
||||
$ts = strtotime($scheduledAt);
|
||||
if ($ts === false) {
|
||||
jsonError('Invalid scheduled_at format');
|
||||
}
|
||||
|
||||
// ── حدود زمنية ──────────────────────────────────────────────
|
||||
// الحد الأدنى: حجز لبعد عشر دقائق لا معنى له — اطلب رحلة عادية.
|
||||
// الحد الأعلى: يومان. قرار المالك — حجز لبعد أسبوعين كلام فاضٍ: الراكب
|
||||
// ينساه، وخطته تتغيّر، ويحتل مكاناً في التوقّع بلا قيمة. اليوم والغد
|
||||
// وبعده هي المدى الذي يلتزم به الناس فعلاً.
|
||||
const SCHEDULE_MIN_LEAD_MINUTES = 30;
|
||||
const SCHEDULE_MAX_DAYS_AHEAD = 2;
|
||||
|
||||
$minutesAhead = ($ts - time()) / 60;
|
||||
|
||||
if ($minutesAhead < SCHEDULE_MIN_LEAD_MINUTES) {
|
||||
jsonError('Scheduled time must be at least ' . SCHEDULE_MIN_LEAD_MINUTES . ' minutes from now');
|
||||
}
|
||||
if ($minutesAhead > SCHEDULE_MAX_DAYS_AHEAD * 24 * 60) {
|
||||
jsonError('يمكنك الحجز حتى ' . SCHEDULE_MAX_DAYS_AHEAD . ' يومين مقدماً فقط');
|
||||
}
|
||||
|
||||
// ── هامش البحث عن سائق ──────────────────────────────────────
|
||||
// نشتقّه من المسافة بدل أن نسأل الراكب: رحلة مطار بعيدة تحتاج وقتاً
|
||||
// أطول لإيجاد سائق وللوصول إليه من رحلة داخل الحي.
|
||||
$leadMinutes = 15;
|
||||
if ($distance > 25) {
|
||||
$leadMinutes = 40;
|
||||
} elseif ($distance > 10) {
|
||||
$leadMinutes = 25;
|
||||
}
|
||||
|
||||
try {
|
||||
// ── منع الحجز المزدوج ───────────────────────────────────
|
||||
// راكب له حجزان في نفس النصف ساعة غالباً ضغط مرتين. الحجز المكرر
|
||||
// ينتج رحلتين حقيقيتين ويُحمّله رسمَي إلغاء.
|
||||
$dup = $con->prepare("
|
||||
SELECT id FROM scheduled_rides
|
||||
WHERE passenger_id = ? AND status = 'scheduled'
|
||||
AND ABS(TIMESTAMPDIFF(MINUTE, scheduled_at, ?)) < 30
|
||||
LIMIT 1
|
||||
");
|
||||
$dup->execute([$passengerId, date('Y-m-d H:i:s', $ts)]);
|
||||
if ($dup->fetchColumn()) {
|
||||
jsonError('You already have a booking around this time', 409);
|
||||
}
|
||||
|
||||
$ins = $con->prepare("
|
||||
INSERT INTO scheduled_rides
|
||||
(passenger_id, start_location, end_location, start_name, end_name,
|
||||
car_type, distance, duration, estimated_price,
|
||||
scheduled_at, lead_minutes, note)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
");
|
||||
$ins->execute([
|
||||
$passengerId, $startLocation, $endLocation, $startName, $endName,
|
||||
$carType, $distance, $duration, $estimated,
|
||||
date('Y-m-d H:i:s', $ts), $leadMinutes,
|
||||
$note ? mb_substr($note, 0, 255) : null,
|
||||
]);
|
||||
|
||||
$id = (int) $con->lastInsertId();
|
||||
|
||||
error_log("[scheduled] حجز #$id للراكب $passengerId في "
|
||||
. date('Y-m-d H:i', $ts) . " (هامش {$leadMinutes}د)");
|
||||
|
||||
jsonSuccess([
|
||||
'id' => $id,
|
||||
'scheduled_at' => date('Y-m-d H:i:s', $ts),
|
||||
'lead_minutes' => $leadMinutes,
|
||||
// نصرّح بأن السعر تقديري: الراكب يجب أن يعرف أن الرقم قد يتغيّر.
|
||||
'price_is_estimate' => true,
|
||||
], 'Ride scheduled');
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log('[scheduled/add] ' . $e->getMessage());
|
||||
jsonError('DB Error', 500);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
// ride/scheduled/cancel.php — إلغاء حجز مسبق
|
||||
//
|
||||
// بلا رسم: لا سائق قُبِل ولا أحد تحرّك. رسم الإلغاء يبدأ من لحظة قبول
|
||||
// السائق، والحجز لم يصل تلك المرحلة بعد.
|
||||
//
|
||||
// أما إن كان الكرون قد حوّله لرحلة فعلية (status=dispatched) فالإلغاء
|
||||
// يتبع مسار الرحلات العادي — cancel_ride_by_passenger.php — بقواعده
|
||||
// وإعفاءاته ورسومه.
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$passengerId = $user_id ?? '';
|
||||
$bookingId = filterRequest('id', 'int');
|
||||
$reason = filterRequest('reason');
|
||||
|
||||
if (empty($passengerId)) jsonError('Unauthorized', 401);
|
||||
if (!$bookingId) jsonError('Missing id');
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare("SELECT * FROM scheduled_rides WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$bookingId]);
|
||||
$booking = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$booking) {
|
||||
jsonError('Booking not found', 404);
|
||||
}
|
||||
|
||||
// الملكية: الحجز يحمل نقاط انطلاق ووجهة الراكب — لا يُلغيه غيره.
|
||||
if ((string) $booking['passenger_id'] !== (string) $passengerId) {
|
||||
error_log("[scheduled/cancel] SECURITY: محاولة إلغاء حجز غير مملوك"
|
||||
. " (booking=$bookingId caller=$passengerId)");
|
||||
jsonError('Forbidden', 403);
|
||||
}
|
||||
|
||||
if ($booking['status'] === 'dispatched') {
|
||||
jsonError('Ride already created — cancel it from the active ride screen', 409);
|
||||
}
|
||||
|
||||
if ($booking['status'] !== 'scheduled') {
|
||||
jsonSuccess(['id' => $bookingId, 'status' => $booking['status']],
|
||||
'Booking is not active');
|
||||
}
|
||||
|
||||
$con->prepare("
|
||||
UPDATE scheduled_rides
|
||||
SET status = 'cancelled', cancelled_reason = ?
|
||||
WHERE id = ? AND status = 'scheduled'
|
||||
")->execute([$reason ? mb_substr($reason, 0, 255) : null, $bookingId]);
|
||||
|
||||
jsonSuccess(['id' => $bookingId, 'status' => 'cancelled'], 'Booking cancelled');
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log('[scheduled/cancel] ' . $e->getMessage());
|
||||
jsonError('DB Error', 500);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
// ride/scheduled/list.php — حجوزات الراكب
|
||||
//
|
||||
// القادمة أولاً ثم الأحدث تاريخاً: الراكب يفتح الشاشة ليرى ما ينتظره،
|
||||
// لا ليتصفّح أرشيفه.
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$passengerId = $user_id ?? '';
|
||||
if (empty($passengerId)) {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$scope = filterRequest('scope') === 'all' ? 'all' : 'upcoming';
|
||||
|
||||
try {
|
||||
$sql = "SELECT * FROM scheduled_rides WHERE passenger_id = ?";
|
||||
if ($scope === 'upcoming') {
|
||||
$sql .= " AND status = 'scheduled' AND scheduled_at >= NOW()";
|
||||
}
|
||||
$sql .= " ORDER BY scheduled_at ASC LIMIT 50";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([$passengerId]);
|
||||
|
||||
jsonSuccess(['bookings' => $stmt->fetchAll(PDO::FETCH_ASSOC)], 'ok');
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log('[scheduled/list] ' . $e->getMessage());
|
||||
jsonError('DB Error', 500);
|
||||
}
|
||||
Reference in New Issue
Block a user