Update: 2026-08-07 05:56:50

This commit is contained in:
Hamza-Ayed
2026-08-07 05:56:50 +03:00
parent 7a932580a3
commit 67465df000
14 changed files with 1257 additions and 9 deletions
+322
View File
@@ -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`;
+30
View File
@@ -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);
}
+130 -9
View File
@@ -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 للسائقين القريبين
+119
View File
@@ -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);
}
+56
View File
@@ -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);
}
+31
View File
@@ -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);
}
+5
View File
@@ -423,6 +423,11 @@ class AppLink {
static String get foodMerchantApprove =>
"$server/food/admin/merchant_approve.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 =
"$paymentServerV2/Admin/v2/financial/dashboard_wallet.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;
// الإحداثيات (أزلنا late لتجنب الأخطاء القاتلة)
@@ -5,6 +5,7 @@ import 'package:siro_driver/constant/api_key.dart';
import 'package:siro_driver/constant/colors.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/scheduled_ride_badge.dart';
import 'package:siro_driver/constant/currency.dart';
import 'package:siro_driver/views/widgets/driver_earnings_badge.dart';
@@ -115,6 +116,13 @@ class OrderRequestPage extends StatelessWidget {
OfferTypeBadge(rideInfo: controller.offerInfo!),
const SizedBox(width: 10),
],
// شارة الرحلة المؤجلة — موعد مضبوط لا طلب فوري
if (ScheduledRideBadge.isScheduled(
controller.scheduleInfo)) ...[
ScheduledRideBadge(
rideInfo: controller.scheduleInfo!),
const SizedBox(width: 10),
],
const Icon(Icons.near_me,
color: Colors.amber, size: 16),
const SizedBox(width: 8),
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intaleq_maps/intaleq_maps.dart'; // لتحديد الأنواع إذا لزم
import '../widgets/scheduled_ride_badge.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/links.dart';
@@ -188,6 +189,12 @@ class RideAvailableCard extends StatelessWidget {
.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') ...[
const SizedBox(height: 8),
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,
),
),
],
),
);
}
}