Update: 2026-08-07 05:56:50
This commit is contained in:
@@ -0,0 +1,322 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* cron_scheduled_rides.php
|
||||||
|
* ─────────────────────────────────────────────────────────────
|
||||||
|
* يحوّل الحجوزات المستحقة إلى رحلات فعلية، ويغذّي توقّع الطلب.
|
||||||
|
*
|
||||||
|
* ── لماذا لا تُنشأ الرحلة وقت الحجز ──
|
||||||
|
* رحلة تُنشأ الآن لموعد بعد ست ساعات ستدور في السوق ست ساعات: تُربك
|
||||||
|
* الإسناد، وتشوّه الخريطة الحرارية، وتُحتسب في كثافة الطلب اللحظية.
|
||||||
|
* الحجز يبقى نيّة حتى يقترب موعده.
|
||||||
|
*
|
||||||
|
* ── وظيفتان ──
|
||||||
|
* ١. تذكير الراكب قبل موعده (ساعة، ثم ربع ساعة)
|
||||||
|
* ٢. إطلاق المستحق: الحجوزات التي بلغ موعدها ناقص هامش البحث
|
||||||
|
* ٣. نشر التوقّع: عدد الحجوزات القادمة لكل خلية جغرافية في Redis،
|
||||||
|
* ليقرأها cron_predictive_demand والخريطة الحرارية
|
||||||
|
*
|
||||||
|
* الثانية هي القيمة الحقيقية: حجز مؤكد لبعد ست ساعات معلومة يقينية،
|
||||||
|
* أدق من أي تنبؤ إحصائي، وتسمح بتوجيه السائقين **قبل** الذروة.
|
||||||
|
*
|
||||||
|
* جدولة مقترحة (crontab): **كل دقيقتين**، لا كل دقيقة.
|
||||||
|
*
|
||||||
|
* الاستعلامات كلها مفهرسة على (status, scheduled_at) وتكلفتها مهملة،
|
||||||
|
* لكن كل تشغيل يعني إنشاء عملية PHP جديدة داخل الحاوية — وهذا هو الحمل
|
||||||
|
* الحقيقي لا الاستعلام. وهوامش الإطلاق ١٥-٤٠ دقيقة، فدقيقة تأخير في
|
||||||
|
* الاستحقاق لا أثر لها إطلاقاً.
|
||||||
|
*
|
||||||
|
* *\/2 * * * * (كل دقيقتين)
|
||||||
|
* docker compose exec -T php php /var/www/backend/bot/cron_scheduled_rides.php
|
||||||
|
*
|
||||||
|
* ⚠️ يجب أن يعمل داخل حاوية php — Redis لا ينشر منافذ.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../core/bootstrap.php';
|
||||||
|
|
||||||
|
// حجز فات موعده بهذا القدر بلا إطلاق يُعتبر منتهياً: الراكب لم يعد
|
||||||
|
// ينتظره، وإطلاقه الآن يرسل له سائقاً لموعد مضى.
|
||||||
|
const SCHEDULE_EXPIRE_GRACE_MINUTES = 30;
|
||||||
|
|
||||||
|
// دقة خلية التوقّع — نفس شبكة cron_predictive_demand (0.01° ≈ 1.1 كم).
|
||||||
|
const SCHEDULE_GRID = 0.01;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$con = Database::get('main');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
fwrite(STDERR, '[scheduled] DB unavailable: ' . $e->getMessage() . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ١) انتهاء صلاحية الحجوزات المهملة
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
$expired = 0;
|
||||||
|
try {
|
||||||
|
$st = $con->prepare("
|
||||||
|
UPDATE scheduled_rides
|
||||||
|
SET status = 'expired'
|
||||||
|
WHERE status = 'scheduled'
|
||||||
|
AND scheduled_at < NOW() - INTERVAL " . SCHEDULE_EXPIRE_GRACE_MINUTES . " MINUTE
|
||||||
|
");
|
||||||
|
$st->execute();
|
||||||
|
$expired = $st->rowCount();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
fwrite(STDERR, '[scheduled] تعذّر تعليم المنتهية: ' . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ١.٥) تذكير الراكب
|
||||||
|
//
|
||||||
|
// تذكيران: قبل ساعة ليرتّب أمره، وقبل ربع ساعة ليستعد للنزول.
|
||||||
|
// الختم في العمود يمنع التكرار — بدونه يصل التذكير على كل مرور للكرون.
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
$reminded = 0;
|
||||||
|
foreach ([[60, 'reminded_60_at', 'رحلتك المحجوزة بعد ساعة'],
|
||||||
|
[15, 'reminded_15_at', 'رحلتك المحجوزة بعد ربع ساعة']] as [$mins, $col, $title]) {
|
||||||
|
try {
|
||||||
|
$st = $con->prepare("
|
||||||
|
SELECT id, passenger_id, scheduled_at, start_name, end_name
|
||||||
|
FROM scheduled_rides
|
||||||
|
WHERE status = 'scheduled'
|
||||||
|
AND `$col` IS NULL
|
||||||
|
AND scheduled_at BETWEEN NOW() AND NOW() + INTERVAL $mins MINUTE
|
||||||
|
LIMIT 100
|
||||||
|
");
|
||||||
|
$st->execute();
|
||||||
|
|
||||||
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
// الختم أولاً ثم الإرسال: لو انقطع التنفيذ بينهما، أسوأ نتيجة
|
||||||
|
// تذكير لم يصل — لا تذكير يصل مئة مرة.
|
||||||
|
$con->prepare("UPDATE scheduled_rides SET `$col` = NOW() WHERE id = ?")
|
||||||
|
->execute([$row['id']]);
|
||||||
|
|
||||||
|
scheduleNotifyPassenger(
|
||||||
|
$con,
|
||||||
|
$row['passenger_id'],
|
||||||
|
$title,
|
||||||
|
'من ' . ($row['start_name'] ?: 'موقعك') . ' في '
|
||||||
|
. date('H:i', strtotime($row['scheduled_at'])),
|
||||||
|
['category' => 'scheduled_reminder', 'scheduled_id' => (string) $row['id']]
|
||||||
|
);
|
||||||
|
$reminded++;
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
fwrite(STDERR, "[scheduled] تعذّر تذكير $mins د: " . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ٢) الحجوزات المستحقة للإطلاق
|
||||||
|
//
|
||||||
|
// المستحق: بلغ الوقت موعدَه ناقص هامشه. الهامش مشتقّ من المسافة وقت
|
||||||
|
// الحجز (رحلة مطار تحتاج وقتاً أطول لإيجاد سائق وللوصول).
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
$dispatched = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$due = $con->prepare("
|
||||||
|
SELECT * FROM scheduled_rides
|
||||||
|
WHERE status = 'scheduled'
|
||||||
|
AND NOW() >= scheduled_at - INTERVAL lead_minutes MINUTE
|
||||||
|
AND scheduled_at >= NOW() - INTERVAL " . SCHEDULE_EXPIRE_GRACE_MINUTES . " MINUTE
|
||||||
|
ORDER BY scheduled_at ASC
|
||||||
|
LIMIT 100
|
||||||
|
");
|
||||||
|
$due->execute();
|
||||||
|
$bookings = $due->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
foreach ($bookings as $b) {
|
||||||
|
// قفل تفاؤلي: مرور ثانٍ للكرون (أو نسخة ثانية منه) لا يطلق نفس
|
||||||
|
// الحجز مرتين. الرحلة المزدوجة تعني سائقين وراكباً واحداً.
|
||||||
|
$lock = $con->prepare("
|
||||||
|
UPDATE scheduled_rides SET status = 'dispatching'
|
||||||
|
WHERE id = ? AND status = 'scheduled'
|
||||||
|
");
|
||||||
|
$lock->execute([$b['id']]);
|
||||||
|
if ($lock->rowCount() === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rideId = scheduleCreateRide($b);
|
||||||
|
|
||||||
|
if ($rideId) {
|
||||||
|
$con->prepare("
|
||||||
|
UPDATE scheduled_rides SET status = 'dispatched', ride_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
")->execute([$rideId, $b['id']]);
|
||||||
|
$dispatched++;
|
||||||
|
error_log("[scheduled] حجز #{$b['id']} → رحلة #$rideId");
|
||||||
|
} else {
|
||||||
|
// نعيده 'scheduled' لا 'failed': المرور القادم يحاول ثانيةً،
|
||||||
|
// وفشل شبكي عابر لا يجوز أن يحرم الراكب رحلته المحجوزة.
|
||||||
|
$con->prepare("
|
||||||
|
UPDATE scheduled_rides SET status = 'scheduled' WHERE id = ?
|
||||||
|
")->execute([$b['id']]);
|
||||||
|
$failed++;
|
||||||
|
error_log("[scheduled] تعذّر إنشاء رحلة للحجز #{$b['id']} — ستُعاد المحاولة");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
fwrite(STDERR, '[scheduled] فشل الإطلاق: ' . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// ٣) نشر التوقّع المؤكد
|
||||||
|
//
|
||||||
|
// هذه هي القيمة التي تتجاوز راحة الراكب: طلب معروف مسبقاً بمكانه
|
||||||
|
// ووقته. يُنشر في Redis ليقرأه التنبؤ والخريطة الحرارية.
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
$cells = 0;
|
||||||
|
try {
|
||||||
|
if (isset($redisLocation) && $redisLocation) {
|
||||||
|
// نافذة ٦ ساعات: أبعد من ذلك لا يفيد توجيه السائقين اليوم.
|
||||||
|
$st = $con->query("
|
||||||
|
SELECT start_location, scheduled_at
|
||||||
|
FROM scheduled_rides
|
||||||
|
WHERE status = 'scheduled'
|
||||||
|
AND scheduled_at BETWEEN NOW() AND NOW() + INTERVAL 6 HOUR
|
||||||
|
");
|
||||||
|
|
||||||
|
$buckets = [];
|
||||||
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
[$lat, $lng] = array_pad(
|
||||||
|
array_map('trim', explode(',', (string) $row['start_location'], 2)), 2, null
|
||||||
|
);
|
||||||
|
if (!is_numeric($lat) || !is_numeric($lng)) continue;
|
||||||
|
|
||||||
|
// المفتاح: خلية جغرافية + الساعة المتوقعة. التجميع بالساعة
|
||||||
|
// يكفي لقرار "أين يجب أن يكون السائقون الساعة السابعة".
|
||||||
|
$cell = round((float) $lat / SCHEDULE_GRID) * SCHEDULE_GRID
|
||||||
|
. ',' . round((float) $lng / SCHEDULE_GRID) * SCHEDULE_GRID;
|
||||||
|
$hour = date('Y-m-d H:00', strtotime($row['scheduled_at']));
|
||||||
|
|
||||||
|
$buckets["$cell|$hour"] = ($buckets["$cell|$hour"] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = 'demand:scheduled';
|
||||||
|
$redisLocation->del($key);
|
||||||
|
if (!empty($buckets)) {
|
||||||
|
$redisLocation->hMSet($key, $buckets);
|
||||||
|
// عمر ساعتين: الكرون يعيد بناءه كل دقيقة، والانتهاء يمنع
|
||||||
|
// بقاء توقّع قديم لو توقّف الكرون.
|
||||||
|
$redisLocation->expire($key, 7200);
|
||||||
|
}
|
||||||
|
$cells = count($buckets);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
fwrite(STDERR, '[scheduled] تعذّر نشر التوقّع: ' . $e->getMessage() . "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[scheduled] أُطلق $dispatched، تعذّر $failed، انتهى $expired،"
|
||||||
|
. " ذُكِّر $reminded، وخلايا التوقّع $cells\n";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ينشئ الرحلة الفعلية من الحجز عبر add_ride.php.
|
||||||
|
*
|
||||||
|
* نستدعي النقطة ذاتها لا نكرّر منطقها: add_ride يسعّر، ويبني الحمولة،
|
||||||
|
* ويشغّل الإسناد (بالدفعات أو البثّ الحر)، ويكتب للقاعدتين. تكرار ذلك
|
||||||
|
* هنا كان سينتج نسخة تتباعد عن الأصل مع أول تعديل.
|
||||||
|
*
|
||||||
|
* السعر يُحسب لحظة الإنشاء لا وقت الحجز: سعر ما قبل ست ساعات لا يعرف
|
||||||
|
* الذروة ولا الطقس ولا كثافة السائقين.
|
||||||
|
*
|
||||||
|
* @return string|null رقم الرحلة، أو null عند الفشل.
|
||||||
|
*/
|
||||||
|
function scheduleCreateRide(array $b): ?string
|
||||||
|
{
|
||||||
|
$url = getenv('INTERNAL_API_BASE') ?: 'http://nginx';
|
||||||
|
$url .= '/ride/rides/add_ride.php';
|
||||||
|
|
||||||
|
[$startLat, $startLng] = array_pad(
|
||||||
|
array_map('trim', explode(',', (string) $b['start_location'], 2)), 2, ''
|
||||||
|
);
|
||||||
|
[$endLat, $endLng] = array_pad(
|
||||||
|
array_map('trim', explode(',', (string) $b['end_location'], 2)), 2, ''
|
||||||
|
);
|
||||||
|
|
||||||
|
$fields = [
|
||||||
|
'passenger_id' => $b['passenger_id'],
|
||||||
|
'start_location' => $b['start_location'],
|
||||||
|
'end_location' => $b['end_location'],
|
||||||
|
'start_lat' => $startLat,
|
||||||
|
'start_lng' => $startLng,
|
||||||
|
'end_lat' => $endLat,
|
||||||
|
'end_lng' => $endLng,
|
||||||
|
'start_name' => $b['start_name'] ?? '',
|
||||||
|
'end_name' => $b['end_name'] ?? '',
|
||||||
|
'car_type' => $b['car_type'],
|
||||||
|
'distance' => $b['distance'],
|
||||||
|
'duration' => $b['duration'],
|
||||||
|
// السعر يُتجاهَل: add_ride يسعّر داخلياً لحظة الإنشاء. نمرّره
|
||||||
|
// للتوثيق فقط — تقدير وقت الحجز لا يعرف الذروة ولا الطقس.
|
||||||
|
'price' => $b['estimated_price'],
|
||||||
|
'scheduled_at' => $b['scheduled_at'],
|
||||||
|
// وسم المصدر: add_ride قد يعامل الرحلة المحجوزة بأولوية أو
|
||||||
|
// هامش مختلف لاحقاً، والوسم يجعل ذلك ممكناً بلا تخمين.
|
||||||
|
'source' => 'scheduled',
|
||||||
|
'scheduled_id' => $b['id'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => http_build_query($fields),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/x-www-form-urlencoded',
|
||||||
|
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($code !== 200 || !$body) {
|
||||||
|
error_log("[scheduled] add_ride رمز=$code رد=" . substr((string) $body, 0, 200));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_decode($body, true);
|
||||||
|
|
||||||
|
// add_ride يرد بـ printSuccess($insertedId) — الرقم في message.
|
||||||
|
$rideId = $json['message'] ?? $json['data'] ?? null;
|
||||||
|
|
||||||
|
return ($rideId && is_scalar($rideId)) ? (string) $rideId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* يرسل إشعاراً للراكب. توكنه مشفَّر في جدول tokens — نفكّه كما يفعل
|
||||||
|
* cancel_ride_by_driver.php بالضبط.
|
||||||
|
*
|
||||||
|
* لا يرمي: فشل التذكير لا يُسقط الكرون ولا يمنع إطلاق الحجوزات.
|
||||||
|
*/
|
||||||
|
function scheduleNotifyPassenger(PDO $con, string $passengerId,
|
||||||
|
string $title, string $body, array $data): void
|
||||||
|
{
|
||||||
|
global $encryptionHelper;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$st = $con->prepare("SELECT token FROM tokens WHERE passengerID = ? ORDER BY id DESC LIMIT 1");
|
||||||
|
$st->execute([$passengerId]);
|
||||||
|
$raw = $st->fetchColumn();
|
||||||
|
if (!$raw) return;
|
||||||
|
|
||||||
|
$token = $raw;
|
||||||
|
if (!empty($encryptionHelper)) {
|
||||||
|
try {
|
||||||
|
$dec = $encryptionHelper->decryptData($raw);
|
||||||
|
if ($dec !== false && $dec !== '') $token = trim($dec);
|
||||||
|
} catch (Throwable $e) { /* نستخدم الخام */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('sendFCM_Internal')) {
|
||||||
|
sendFCM_Internal($token, $title, $body, $data, 'scheduled_reminder', false);
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('[scheduled] تعذّر إرسال التذكير: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- 2026_08_07_scheduled_rides.sql — الحجز المسبق
|
||||||
|
--
|
||||||
|
-- شريحة مفقودة بالكامل: رحلات المطار، المواعيد الطبية، الدوام اليومي،
|
||||||
|
-- رحلات الفجر. أعلى الشرائح استعداداً للدفع وأقلها حساسية للسعر.
|
||||||
|
--
|
||||||
|
-- والزاوية الأهم: الحجز ليس ميزة راكب فقط — هو **مصدر توقّع طلب مؤكد**.
|
||||||
|
-- رحلة محجوزة لبعد ست ساعات معلومة يقينية تُغذّي cron_predictive_demand
|
||||||
|
-- والخريطة الحرارية بشيء أدق من أي تنبؤ إحصائي، وتسمح بتوجيه السائقين
|
||||||
|
-- قبل الذروة لا بعدها.
|
||||||
|
--
|
||||||
|
-- ⚠️ يُنفَّذ على قاعدة primary (jorSiroDB) وحدها: الكرون والتنبؤ يقرآن
|
||||||
|
-- من primary، والرحلة الفعلية تُنشأ عبر add_ride.php الذي يكتب للاثنتين.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `scheduled_rides` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
|
`passenger_id` VARCHAR(100) NOT NULL,
|
||||||
|
|
||||||
|
-- نقاط الرحلة — نفس صيغة ride.start_location ("lat,lng")
|
||||||
|
`start_location` VARCHAR(255) NOT NULL,
|
||||||
|
`end_location` VARCHAR(255) NOT NULL,
|
||||||
|
`start_name` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
`end_name` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
`car_type` VARCHAR(20) NOT NULL DEFAULT 'Speed',
|
||||||
|
`distance` FLOAT NOT NULL DEFAULT 0,
|
||||||
|
`duration` INT NOT NULL DEFAULT 0 COMMENT 'ثوانٍ',
|
||||||
|
|
||||||
|
-- السعر التقديري وقت الحجز. ليس ملزماً: التسعير النهائي يحدث لحظة
|
||||||
|
-- إنشاء الرحلة الفعلية، فسعر ما قبل ست ساعات لا يعرف الذروة ولا الطقس.
|
||||||
|
`estimated_price` DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
`scheduled_at` DATETIME NOT NULL COMMENT 'موعد انطلاق الرحلة كما طلبه الراكب',
|
||||||
|
|
||||||
|
-- كم دقيقة قبل الموعد نبدأ البحث عن سائق. رحلة مطار تحتاج هامشاً أطول
|
||||||
|
-- من رحلة داخل الحي، والراكب لا يُطلب منه ضبطها — نشتقها من المسافة.
|
||||||
|
`lead_minutes` SMALLINT UNSIGNED NOT NULL DEFAULT 15,
|
||||||
|
|
||||||
|
`status` VARCHAR(20) NOT NULL DEFAULT 'scheduled'
|
||||||
|
COMMENT 'scheduled = بانتظار موعده | dispatching = قفل مؤقت أثناء الإنشاء (يمنع الإطلاق المزدوج) | dispatched = أُنشئت رحلته | cancelled | expired = فات موعده بلا إسناد',
|
||||||
|
|
||||||
|
-- الرحلة الفعلية التي وُلدت من هذا الحجز.
|
||||||
|
`ride_id` VARCHAR(20) NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
-- أختام التذكيرات: يُكتب الختم مرة واحدة فيمنع تكرار الإشعار على كل
|
||||||
|
-- مرور للكرون. راكب يصله «رحلتك بعد ساعة» ستين مرة يُلغي الحجز ويحذف
|
||||||
|
-- التطبيق.
|
||||||
|
`reminded_60_at` DATETIME NULL DEFAULT NULL,
|
||||||
|
`reminded_15_at` DATETIME NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
`note` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
`cancelled_reason` VARCHAR(255) NULL DEFAULT NULL,
|
||||||
|
|
||||||
|
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
-- المفتاح الذي يعتمد عليه الكرون: يمسح المستحق دون قراءة الجدول كله.
|
||||||
|
KEY `idx_due` (`status`, `scheduled_at`),
|
||||||
|
KEY `idx_passenger` (`passenger_id`, `status`),
|
||||||
|
KEY `idx_ride` (`ride_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||||
|
COMMENT='الحجوزات المسبقة — ومصدر توقّع الطلب المؤكد';
|
||||||
|
|
||||||
|
-- للتراجع:
|
||||||
|
-- DROP TABLE `scheduled_rides`;
|
||||||
@@ -261,3 +261,33 @@ function getMoneyHardCap(string $currency): float
|
|||||||
// والصرف الخاطئ لا يُسترد.
|
// والصرف الخاطئ لا يُسترد.
|
||||||
return $caps[strtoupper($currency)] ?? min($caps);
|
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],
|
'duration' => $payloadData[15],
|
||||||
'passengerRate' => $payloadData[33],
|
'passengerRate' => $payloadData[33],
|
||||||
'passengerId' => $payloadData[7],
|
'passengerId' => $payloadData[7],
|
||||||
|
// رحلة محجوزة: يقرأها available_rides_page و order_request_page
|
||||||
|
// ونافذة الأوفرلي. غيابها = رحلة لحظية عادية.
|
||||||
|
'is_scheduled' => $payloadData[39] ?? '',
|
||||||
|
'scheduled_at' => $payloadData[40] ?? '',
|
||||||
], $extraMarketData);
|
], $extraMarketData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,13 +117,89 @@ function broadcastRideToMarket($rideId, $lat, $lng, $payloadData, $extraMarketDa
|
|||||||
error_log("[add_ride] Request started. passenger_id=" . ($_POST['passenger_id'] ?? '?'));
|
error_log("[add_ride] Request started. passenger_id=" . ($_POST['passenger_id'] ?? '?'));
|
||||||
|
|
||||||
// ── 1. Input ───────────────────────────────────────────────────
|
// ── 1. Input ───────────────────────────────────────────────────
|
||||||
|
// وسم الحجز المسبق: يمرّره bot/cron_scheduled_rides.php حين يحوّل حجزاً
|
||||||
|
// لرحلة فعلية. الرحلة العادية تتركه فارغاً فلا يظهر شيء في التطبيقات.
|
||||||
|
$isScheduledRide = filterRequest("source") === 'scheduled';
|
||||||
|
$scheduledAtLabel = filterRequest("scheduled_at") ?: '';
|
||||||
|
|
||||||
$start_location = filterRequest("start_location");
|
$start_location = filterRequest("start_location");
|
||||||
$end_location = filterRequest("end_location");
|
$end_location = filterRequest("end_location");
|
||||||
$price = filterRequest("price");
|
$price = filterRequest("price");
|
||||||
$price_token = filterRequest("price_token");
|
$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');
|
$driver_id = (string)(filterRequest("driver_id") ?: '0');
|
||||||
$status = filterRequest("status") ?: 'nothing';
|
$status = filterRequest("status") ?: 'nothing';
|
||||||
$price_for_driver = filterRequest("price_for_driver") ?: ($price ?: '0');
|
$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;
|
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
|
// SECURE PRICE TOKEN VERIFICATION
|
||||||
if (empty($price_token)) {
|
if (!$isInternalScheduled && empty($price_token)) {
|
||||||
error_log("[add_ride] Security failed — price_token is missing.");
|
error_log("[add_ride] Security failed — price_token is missing.");
|
||||||
printFailure("Secure price token is required");
|
printFailure("Secure price token is required");
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$decrypted = isset($encryptionHelper) ? $encryptionHelper->decryptData($price_token) : false;
|
$decrypted = (!$isInternalScheduled && isset($encryptionHelper))
|
||||||
if (!$decrypted) {
|
? $encryptionHelper->decryptData($price_token) : false;
|
||||||
|
if (!$isInternalScheduled && !$decrypted) {
|
||||||
error_log("[add_ride] Security failed — failed to decrypt price_token.");
|
error_log("[add_ride] Security failed — failed to decrypt price_token.");
|
||||||
printFailure("Invalid or tampered price token");
|
printFailure("Invalid or tampered price token");
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tokenData = json_decode($decrypted, true);
|
$tokenData = $decrypted ? json_decode($decrypted, true) : null;
|
||||||
if (!$tokenData || !isset($tokenData['expires']) || $tokenData['expires'] < time()) {
|
if (!$isInternalScheduled
|
||||||
|
&& (!$tokenData || !isset($tokenData['expires']) || $tokenData['expires'] < time())) {
|
||||||
error_log("[add_ride] Security failed — token is expired or invalid JSON.");
|
error_log("[add_ride] Security failed — token is expired or invalid JSON.");
|
||||||
printFailure("Price token has expired, please request estimation again");
|
printFailure("Price token has expired, please request estimation again");
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tokenData['passenger_id'] != $passenger_id) {
|
if (!$isInternalScheduled && $tokenData['passenger_id'] != $passenger_id) {
|
||||||
error_log("[add_ride] Security failed — passenger_id mismatch.");
|
error_log("[add_ride] Security failed — passenger_id mismatch.");
|
||||||
printFailure("Tampered price token (passenger mismatch)");
|
printFailure("Tampered price token (passenger mismatch)");
|
||||||
exit;
|
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));
|
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)");
|
printFailure("Tampered price token (route mismatch)");
|
||||||
exit;
|
exit;
|
||||||
@@ -394,6 +508,13 @@ try {
|
|||||||
// اللي بتوصلها البيانات كـ List مش كـ Map مسمّى مثل FCM
|
// اللي بتوصلها البيانات كـ List مش كـ Map مسمّى مثل FCM
|
||||||
isset($extraDispatchData['driver_earnings_extra']) ? $extraDispatchData['driver_earnings_extra'] : '',
|
isset($extraDispatchData['driver_earnings_extra']) ? $extraDispatchData['driver_earnings_extra'] : '',
|
||||||
isset($extraDispatchData['driver_earnings_currency']) ? $extraDispatchData['driver_earnings_currency'] : '',
|
isset($extraDispatchData['driver_earnings_currency']) ? $extraDispatchData['driver_earnings_currency'] : '',
|
||||||
|
// 🆕 Index 37/38: وسم العرض الحصري في الإسناد بالدفعات
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
// 🆕 Index 39/40: رحلة محجوزة مسبقاً — السائق يجب أن يعرف أنها
|
||||||
|
// ليست طلباً لحظياً بل موعد مضبوط، وأن الراكب ينتظره في وقت محدد.
|
||||||
|
$isScheduledRide ? '1' : '',
|
||||||
|
$isScheduledRide ? (string) $scheduledAtLabel : '',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Direct dispatch للسائقين القريبين
|
// 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);
|
||||||
|
}
|
||||||
@@ -423,6 +423,11 @@ class AppLink {
|
|||||||
static String get foodMerchantApprove =>
|
static String get foodMerchantApprove =>
|
||||||
"$server/food/admin/merchant_approve.php";
|
"$server/food/admin/merchant_approve.php";
|
||||||
static String get foodPayouts => "$server/food/admin/payouts.php";
|
static String get foodPayouts => "$server/food/admin/payouts.php";
|
||||||
|
|
||||||
|
/// تسوية أرباح سائق التوصيل — أجور التوصيل مقاصّةً مع ديون النقد.
|
||||||
|
/// تعمل بوضعين: action=preview يحسب بلا أثر، وaction=execute يصرف.
|
||||||
|
static String get foodCourierSettlement =>
|
||||||
|
"$server/food/admin/courier_settlement.php";
|
||||||
static String dashboardWalletV2 =
|
static String dashboardWalletV2 =
|
||||||
"$paymentServerV2/Admin/v2/financial/dashboard_wallet.php";
|
"$paymentServerV2/Admin/v2/financial/dashboard_wallet.php";
|
||||||
static String auditLogsV2 = "$server/Admin/v2/security/audit_logs.php";
|
static String auditLogsV2 = "$server/Admin/v2/security/audit_logs.php";
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import '../../constant/links.dart';
|
||||||
|
import '../../views/widgets/snackbar.dart';
|
||||||
|
import '../functions/crud.dart';
|
||||||
|
|
||||||
|
/// تسوية أرباح سائق التوصيل.
|
||||||
|
///
|
||||||
|
/// خطوتان لا واحدة: **معاينة** تحسب وتعرض بلا أثر، ثم **تنفيذ** يصرف.
|
||||||
|
/// صرف المال يجب أن يسبقه اطّلاع، لا أن يكون أثراً جانبياً لفتح شاشة.
|
||||||
|
class CourierSettlementController extends GetxController {
|
||||||
|
final CRUD _crud = CRUD();
|
||||||
|
|
||||||
|
final courierCtrl = TextEditingController();
|
||||||
|
final startCtrl = TextEditingController();
|
||||||
|
final endCtrl = TextEditingController();
|
||||||
|
|
||||||
|
final isLoading = false.obs;
|
||||||
|
final isExecuting = false.obs;
|
||||||
|
|
||||||
|
/// نتيجة المعاينة. فارغة = لم تُطلب بعد.
|
||||||
|
final preview = Rxn<Map<String, dynamic>>();
|
||||||
|
|
||||||
|
/// نتيجة التنفيذ. وجودها يقفل زر الصرف — لا تُنفَّذ الدورة مرتين.
|
||||||
|
final executed = Rxn<Map<String, dynamic>>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
// الافتراضي: الأسبوع المنتهي. أشيع فترة تسوية، ويوفّر كتابة يدوية.
|
||||||
|
final now = DateTime.now();
|
||||||
|
final weekAgo = now.subtract(const Duration(days: 7));
|
||||||
|
startCtrl.text = _fmt(weekAgo, startOfDay: true);
|
||||||
|
endCtrl.text = _fmt(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onClose() {
|
||||||
|
courierCtrl.dispose();
|
||||||
|
startCtrl.dispose();
|
||||||
|
endCtrl.dispose();
|
||||||
|
super.onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmt(DateTime d, {bool startOfDay = false}) {
|
||||||
|
final s = '${d.year}-${_2(d.month)}-${_2(d.day)}';
|
||||||
|
return startOfDay ? '$s 00:00:00' : '$s ${_2(d.hour)}:${_2(d.minute)}:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _2(int n) => n.toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
bool get _valid {
|
||||||
|
if (courierCtrl.text.trim().isEmpty) {
|
||||||
|
mySnackbarError('أدخل معرّف السائق');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (startCtrl.text.trim().isEmpty || endCtrl.text.trim().isEmpty) {
|
||||||
|
mySnackbarError('حدّد الفترة');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> get _payload => {
|
||||||
|
'courier_id': courierCtrl.text.trim(),
|
||||||
|
'period_start': startCtrl.text.trim(),
|
||||||
|
'period_end': endCtrl.text.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// يحسب الصافي بلا أي أثر على القاعدة أو المحفظة.
|
||||||
|
Future<void> runPreview() async {
|
||||||
|
if (!_valid || isLoading.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
// تغيير السائق أو الفترة يُبطل نتيجة تنفيذ سابقة — وإلا بقيت معروضة
|
||||||
|
// فأوهمت الموظف أن الدورة الجديدة صُرفت.
|
||||||
|
executed.value = null;
|
||||||
|
preview.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final res = await _crud.post(
|
||||||
|
link: AppLink.foodCourierSettlement,
|
||||||
|
payload: {..._payload, 'action': 'preview'},
|
||||||
|
);
|
||||||
|
final data = _extract(res);
|
||||||
|
if (data == null) return;
|
||||||
|
preview.value = data;
|
||||||
|
} catch (e) {
|
||||||
|
mySnackbarError('تعذّر الاتصال بالخادم');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// يثبّت الدورة ويصرف إن كان الصافي موجباً.
|
||||||
|
Future<void> runExecute() async {
|
||||||
|
if (!_valid || isExecuting.value || preview.value == null) return;
|
||||||
|
|
||||||
|
isExecuting.value = true;
|
||||||
|
try {
|
||||||
|
final res = await _crud.post(
|
||||||
|
link: AppLink.foodCourierSettlement,
|
||||||
|
payload: {..._payload, 'action': 'execute'},
|
||||||
|
);
|
||||||
|
final data = _extract(res);
|
||||||
|
if (data == null) return;
|
||||||
|
|
||||||
|
executed.value = data;
|
||||||
|
|
||||||
|
switch (data['status']) {
|
||||||
|
case 'paid':
|
||||||
|
mySnackbarSuccess('صُرفت التسوية #${data['settlement_id']}');
|
||||||
|
break;
|
||||||
|
case 'carried':
|
||||||
|
mySnackbarSuccess('الصافي سالب — رُحّل للدورة القادمة');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// فشل التحويل يُعرض صراحةً: الدورة مثبّتة والمال لم يصل.
|
||||||
|
mySnackbarError('ثُبّتت الدورة لكن التحويل فشل — تحتاج تسوية يدوية');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
mySnackbarError('تعذّر الاتصال بالخادم');
|
||||||
|
} finally {
|
||||||
|
isExecuting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _extract(dynamic res) {
|
||||||
|
final decoded = res is String ? jsonDecode(res) : res;
|
||||||
|
if (decoded is! Map) {
|
||||||
|
mySnackbarError('رد غير مفهوم من الخادم');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (decoded['status'] != 'success') {
|
||||||
|
mySnackbarError(decoded['message']?.toString() ?? 'فشلت العملية');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final data = decoded['message'] ?? decoded['data'];
|
||||||
|
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import '../../../controller/admin/courier_settlement_controller.dart';
|
||||||
|
|
||||||
|
/// شاشة تسوية أرباح سائق التوصيل.
|
||||||
|
///
|
||||||
|
/// المسار: إدخال → معاينة → تنفيذ. زر التنفيذ لا يظهر قبل المعاينة.
|
||||||
|
class CourierSettlementPage extends StatelessWidget {
|
||||||
|
const CourierSettlementPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final c = Get.put(CourierSettlementController());
|
||||||
|
|
||||||
|
return Directionality(
|
||||||
|
textDirection: TextDirection.rtl,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('تسوية أرباح التوصيل')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_Card(
|
||||||
|
title: 'الفترة والسائق',
|
||||||
|
icon: Icons.tune,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: c.courierCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'معرّف السائق',
|
||||||
|
hintText: 'driver id',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: c.startCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'من',
|
||||||
|
hintText: 'YYYY-MM-DD HH:MM:SS',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: c.endCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'إلى',
|
||||||
|
hintText: 'YYYY-MM-DD HH:MM:SS',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Obx(() => SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: c.isLoading.value ? null : c.runPreview,
|
||||||
|
icon: c.isLoading.value
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Icons.calculate_outlined),
|
||||||
|
label: Text(c.isLoading.value ? 'جارٍ الحساب…' : 'احسب الصافي'),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Obx(() => c.preview.value == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: _Result(c: c)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Result extends StatelessWidget {
|
||||||
|
const _Result({required this.c});
|
||||||
|
final CourierSettlementController c;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final p = c.preview.value!;
|
||||||
|
final done = c.executed.value;
|
||||||
|
|
||||||
|
final num net = p['net_decimal'] ?? 0;
|
||||||
|
final String cur = p['currency']?.toString() ?? '';
|
||||||
|
final bool willPay = net > 0;
|
||||||
|
|
||||||
|
return _Card(
|
||||||
|
title: 'الصافي',
|
||||||
|
icon: Icons.receipt_long_outlined,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_line('الطلبات المسلَّمة', '${p['orders_count'] ?? 0}'),
|
||||||
|
const Divider(height: 20),
|
||||||
|
_line('أجور التوصيل', '+ ${_dec(p['payout_total'])} $cur',
|
||||||
|
color: Colors.green.shade700),
|
||||||
|
_line('دَين النقد', '− ${_dec(p['cash_debt_total'])} $cur',
|
||||||
|
color: Colors.orange.shade800),
|
||||||
|
if ((p['carried_over'] ?? 0) != 0)
|
||||||
|
_line('مُرحَّل من دورة سابقة', '− ${_dec(p['carried_over'])} $cur',
|
||||||
|
color: Colors.orange.shade800),
|
||||||
|
const Divider(height: 20),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('الصافي',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
Text('$net $cur',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
color: willPay ? Colors.green.shade700 : Colors.red.shade700,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
willPay
|
||||||
|
? 'سيُصرف هذا المبلغ لمحفظة السائق.'
|
||||||
|
// السالب لا يُخصم من محفظته: قد تكون فارغة، والخصم القسري
|
||||||
|
// يفاجئه ويوقفه عن العمل. الدَين لا يضيع — يُطرح لاحقاً.
|
||||||
|
: 'الصافي سالب — يُرحَّل للدورة القادمة ولا يُخصم من محفظته.',
|
||||||
|
style: const TextStyle(fontSize: 12, height: 1.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (done == null)
|
||||||
|
Obx(() => SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 46,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: c.isExecuting.value
|
||||||
|
? null
|
||||||
|
: () => _confirm(context, c, net, cur, willPay),
|
||||||
|
icon: c.isExecuting.value
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: Icon(willPay ? Icons.payments_outlined : Icons.archive_outlined),
|
||||||
|
label: Text(willPay ? 'ثبّت واصرف' : 'ثبّت ورحّل'),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: done['status'] == 'failed'
|
||||||
|
? Colors.red.withOpacity(0.08)
|
||||||
|
: Colors.green.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'تسوية #${done['settlement_id']} — ${_statusLabel(done['status'])}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// تأكيد قبل الصرف. التثبيت لا يُلغى، والدورة لا تُنفَّذ مرتين — فالضغطة
|
||||||
|
/// الواحدة يجب أن تكون مقصودة.
|
||||||
|
void _confirm(BuildContext context, CourierSettlementController c, num net,
|
||||||
|
String cur, bool willPay) {
|
||||||
|
Get.defaultDialog(
|
||||||
|
title: 'تأكيد التسوية',
|
||||||
|
middleText: willPay
|
||||||
|
? 'سيُصرف $net $cur لمحفظة السائق. لا يمكن التراجع.'
|
||||||
|
: 'سيُثبَّت دَين $net $cur ويُرحَّل للدورة القادمة.',
|
||||||
|
textConfirm: 'تأكيد',
|
||||||
|
textCancel: 'إلغاء',
|
||||||
|
onConfirm: () {
|
||||||
|
Get.back();
|
||||||
|
c.runExecute();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _statusLabel(dynamic s) => switch (s) {
|
||||||
|
'paid' => 'صُرفت بنجاح',
|
||||||
|
'carried' => 'رُحّلت للدورة القادمة',
|
||||||
|
'failed' => 'ثُبّتت والتحويل فشل — تحتاج تسوية يدوية',
|
||||||
|
_ => '$s',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _dec(dynamic smallestUnit) {
|
||||||
|
final v = int.tryParse('$smallestUnit') ?? 0;
|
||||||
|
// الفلس الأردني: ثلاث خانات عشرية. يطابق FOOD_CURRENCY_DIVISOR=1000.
|
||||||
|
return (v / 1000).toStringAsFixed(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _line(String label, String value, {Color? color}) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(fontSize: 13)),
|
||||||
|
Text(value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13, fontWeight: FontWeight.w600, color: color)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Card extends StatelessWidget {
|
||||||
|
const _Card({required this.title, required this.icon, required this.child});
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Card(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
Icon(icon, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, fontSize: 15)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -65,6 +65,21 @@ class OrderRequestController extends GetxController
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// معلومات الحجز المسبق إن كانت الرحلة مؤجلة.
|
||||||
|
///
|
||||||
|
/// يصل الحقلان من buildMarketPayload عبر السوكِت (خريطة)، أو من الفهرسين
|
||||||
|
/// 39/40 عبر FCM (قائمة). _getValueAt يوحّد الشكلين.
|
||||||
|
Map<String, dynamic>? get scheduleInfo {
|
||||||
|
final flag = myMapData?['is_scheduled'] ?? _getValueAt(39);
|
||||||
|
final v = flag?.toString();
|
||||||
|
if (v != '1' && v != 'true') return null;
|
||||||
|
return {
|
||||||
|
'is_scheduled': '1',
|
||||||
|
'scheduled_at':
|
||||||
|
(myMapData?['scheduled_at'] ?? _getValueAt(40))?.toString() ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
IntaleqMapController? mapController;
|
IntaleqMapController? mapController;
|
||||||
|
|
||||||
// الإحداثيات (أزلنا late لتجنب الأخطاء القاتلة)
|
// الإحداثيات (أزلنا late لتجنب الأخطاء القاتلة)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:siro_driver/constant/api_key.dart';
|
|||||||
import 'package:siro_driver/constant/colors.dart';
|
import 'package:siro_driver/constant/colors.dart';
|
||||||
import 'package:siro_driver/controller/home/captin/order_request_controller.dart';
|
import 'package:siro_driver/controller/home/captin/order_request_controller.dart';
|
||||||
import 'package:siro_driver/views/widgets/offer_type_badge.dart';
|
import 'package:siro_driver/views/widgets/offer_type_badge.dart';
|
||||||
|
import 'package:siro_driver/views/widgets/scheduled_ride_badge.dart';
|
||||||
import 'package:siro_driver/constant/currency.dart';
|
import 'package:siro_driver/constant/currency.dart';
|
||||||
import 'package:siro_driver/views/widgets/driver_earnings_badge.dart';
|
import 'package:siro_driver/views/widgets/driver_earnings_badge.dart';
|
||||||
|
|
||||||
@@ -115,6 +116,13 @@ class OrderRequestPage extends StatelessWidget {
|
|||||||
OfferTypeBadge(rideInfo: controller.offerInfo!),
|
OfferTypeBadge(rideInfo: controller.offerInfo!),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
|
// شارة الرحلة المؤجلة — موعد مضبوط لا طلب فوري
|
||||||
|
if (ScheduledRideBadge.isScheduled(
|
||||||
|
controller.scheduleInfo)) ...[
|
||||||
|
ScheduledRideBadge(
|
||||||
|
rideInfo: controller.scheduleInfo!),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
],
|
||||||
const Icon(Icons.near_me,
|
const Icon(Icons.near_me,
|
||||||
color: Colors.amber, size: 16),
|
color: Colors.amber, size: 16),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:intaleq_maps/intaleq_maps.dart'; // لتحديد الأنواع إذا لزم
|
import 'package:intaleq_maps/intaleq_maps.dart'; // لتحديد الأنواع إذا لزم
|
||||||
|
|
||||||
|
import '../widgets/scheduled_ride_badge.dart';
|
||||||
import '../../constant/box_name.dart';
|
import '../../constant/box_name.dart';
|
||||||
import '../../constant/colors.dart';
|
import '../../constant/colors.dart';
|
||||||
import '../../constant/links.dart';
|
import '../../constant/links.dart';
|
||||||
@@ -188,6 +189,12 @@ class RideAvailableCard extends StatelessWidget {
|
|||||||
.copyWith(color: AppColor.greenColor, fontSize: 13),
|
.copyWith(color: AppColor.greenColor, fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// شارة الرحلة المؤجلة — معلومة دائمة عن الرحلة، تظهر هنا
|
||||||
|
// وفي نافذة الطلب والأوفرلي معاً.
|
||||||
|
if (ScheduledRideBadge.isScheduled(rideInfo)) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ScheduledRideBadge(rideInfo: rideInfo, compact: true),
|
||||||
|
],
|
||||||
if (rideInfo['has_steps']?.toString() == 'true') ...[
|
if (rideInfo['has_steps']?.toString() == 'true') ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
/// شارة «رحلة مؤجلة».
|
||||||
|
///
|
||||||
|
/// السائق يجب أن يعرف أن هذه ليست رحلة لحظية بل موعد مضبوط: الراكب
|
||||||
|
/// ينتظره في وقت محدد، والتأخر عليه يختلف عن التأخر على طلب فوري.
|
||||||
|
///
|
||||||
|
/// المصدر: حقلا `is_scheduled` و`scheduled_at` في حمولة الرحلة —
|
||||||
|
/// يضيفهما buildMarketPayload في backend/ride/rides/add_ride.php.
|
||||||
|
/// غيابهما = رحلة عادية، فلا تظهر الشارة إطلاقاً.
|
||||||
|
///
|
||||||
|
/// بخلاف شارة «طلب خاص/عام» التي تظهر في نافذة العرض فقط، هذه تظهر في
|
||||||
|
/// **كل** السطوح: القائمة، ونافذة الطلب، والأوفرلي — لأن القيمة هنا
|
||||||
|
/// معلومة دائمة عن الرحلة لا حالة مؤقتة تنقضي بعد ثوانٍ.
|
||||||
|
class ScheduledRideBadge extends StatelessWidget {
|
||||||
|
final Map rideInfo;
|
||||||
|
|
||||||
|
/// حجم مصغّر لبطاقات القائمة.
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
|
const ScheduledRideBadge({
|
||||||
|
super.key,
|
||||||
|
required this.rideInfo,
|
||||||
|
this.compact = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
static bool isScheduled(Map? info) {
|
||||||
|
final v = info?['is_scheduled']?.toString();
|
||||||
|
return v == '1' || v == 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// وقت الموعد بصيغة قصيرة. يرجع فارغاً إن لم يصل أو تعذّر تحليله —
|
||||||
|
/// الشارة تبقى ظاهرة بلا وقت بدل أن تختفي.
|
||||||
|
String get _timeLabel {
|
||||||
|
final raw = rideInfo['scheduled_at']?.toString();
|
||||||
|
if (raw == null || raw.isEmpty) return '';
|
||||||
|
|
||||||
|
final dt = DateTime.tryParse(raw);
|
||||||
|
if (dt == null) return '';
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final sameDay =
|
||||||
|
dt.year == now.year && dt.month == now.month && dt.day == now.day;
|
||||||
|
|
||||||
|
final hh = dt.hour.toString().padLeft(2, '0');
|
||||||
|
final mm = dt.minute.toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
if (sameDay) return '$hh:$mm';
|
||||||
|
return '${dt.day}/${dt.month} $hh:$mm';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!isScheduled(rideInfo)) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
const color = Color(0xFF6A5ACD); // بنفسجي — لا يشبه ألوان الحالات الأخرى
|
||||||
|
final t = _timeLabel;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: compact ? 8 : 12,
|
||||||
|
vertical: compact ? 4 : 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.12),
|
||||||
|
borderRadius: BorderRadius.circular(compact ? 12 : 20),
|
||||||
|
border: Border.all(color: color.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.schedule, color: color, size: compact ? 14 : 16),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
t.isEmpty ? 'رحلة مؤجلة'.tr : '${'رحلة مؤجلة'.tr} · $t',
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: compact ? 11 : 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user