From 67465df000846dc7338c12bfdbb0983a3a9dfad7 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 7 Aug 2026 05:56:50 +0300 Subject: [PATCH] Update: 2026-08-07 05:56:50 --- backend/bot/cron_scheduled_rides.php | 322 ++++++++++++++++++ .../migrations/2026_08_07_scheduled_rides.sql | 65 ++++ backend/ride/pricing/pricing_helper.php | 30 ++ backend/ride/rides/add_ride.php | 139 +++++++- backend/ride/scheduled/add.php | 119 +++++++ backend/ride/scheduled/cancel.php | 56 +++ backend/ride/scheduled/list.php | 31 ++ siro_admin/lib/constant/links.dart | 5 + .../admin/courier_settlement_controller.dart | 142 ++++++++ .../admin/food/courier_settlement_page.dart | 240 +++++++++++++ .../home/captin/order_request_controller.dart | 15 + .../orderCaptin/order_request_page.dart | 8 + .../notification/available_rides_page.dart | 7 + .../views/widgets/scheduled_ride_badge.dart | 87 +++++ 14 files changed, 1257 insertions(+), 9 deletions(-) create mode 100644 backend/bot/cron_scheduled_rides.php create mode 100644 backend/migrations/2026_08_07_scheduled_rides.sql create mode 100644 backend/ride/scheduled/add.php create mode 100644 backend/ride/scheduled/cancel.php create mode 100644 backend/ride/scheduled/list.php create mode 100644 siro_admin/lib/controller/admin/courier_settlement_controller.dart create mode 100644 siro_admin/lib/views/admin/food/courier_settlement_page.dart create mode 100644 siro_driver/lib/views/widgets/scheduled_ride_badge.dart diff --git a/backend/bot/cron_scheduled_rides.php b/backend/bot/cron_scheduled_rides.php new file mode 100644 index 00000000..51c469ea --- /dev/null +++ b/backend/bot/cron_scheduled_rides.php @@ -0,0 +1,322 @@ +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()); + } +} diff --git a/backend/migrations/2026_08_07_scheduled_rides.sql b/backend/migrations/2026_08_07_scheduled_rides.sql new file mode 100644 index 00000000..1b529383 --- /dev/null +++ b/backend/migrations/2026_08_07_scheduled_rides.sql @@ -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`; diff --git a/backend/ride/pricing/pricing_helper.php b/backend/ride/pricing/pricing_helper.php index 0cb1c6fb..d3bc60f2 100644 --- a/backend/ride/pricing/pricing_helper.php +++ b/backend/ride/pricing/pricing_helper.php @@ -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); +} diff --git a/backend/ride/rides/add_ride.php b/backend/ride/rides/add_ride.php index 5aee2fa8..36b8f8b3 100644 --- a/backend/ride/rides/add_ride.php +++ b/backend/ride/rides/add_ride.php @@ -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 للسائقين القريبين diff --git a/backend/ride/scheduled/add.php b/backend/ride/scheduled/add.php new file mode 100644 index 00000000..6f2c764f --- /dev/null +++ b/backend/ride/scheduled/add.php @@ -0,0 +1,119 @@ + 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); +} diff --git a/backend/ride/scheduled/cancel.php b/backend/ride/scheduled/cancel.php new file mode 100644 index 00000000..57d82fd2 --- /dev/null +++ b/backend/ride/scheduled/cancel.php @@ -0,0 +1,56 @@ +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); +} diff --git a/backend/ride/scheduled/list.php b/backend/ride/scheduled/list.php new file mode 100644 index 00000000..34807a0b --- /dev/null +++ b/backend/ride/scheduled/list.php @@ -0,0 +1,31 @@ += 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); +} diff --git a/siro_admin/lib/constant/links.dart b/siro_admin/lib/constant/links.dart index 0a50d30a..f1fe4b21 100644 --- a/siro_admin/lib/constant/links.dart +++ b/siro_admin/lib/constant/links.dart @@ -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"; diff --git a/siro_admin/lib/controller/admin/courier_settlement_controller.dart b/siro_admin/lib/controller/admin/courier_settlement_controller.dart new file mode 100644 index 00000000..4b38d364 --- /dev/null +++ b/siro_admin/lib/controller/admin/courier_settlement_controller.dart @@ -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>(); + + /// نتيجة التنفيذ. وجودها يقفل زر الصرف — لا تُنفَّذ الدورة مرتين. + final executed = Rxn>(); + + @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 get _payload => { + 'courier_id': courierCtrl.text.trim(), + 'period_start': startCtrl.text.trim(), + 'period_end': endCtrl.text.trim(), + }; + + /// يحسب الصافي بلا أي أثر على القاعدة أو المحفظة. + Future 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 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? _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.from(data) : null; + } +} diff --git a/siro_admin/lib/views/admin/food/courier_settlement_page.dart b/siro_admin/lib/views/admin/food/courier_settlement_page.dart new file mode 100644 index 00000000..f250b000 --- /dev/null +++ b/siro_admin/lib/views/admin/food/courier_settlement_page.dart @@ -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, + ], + ), + ), + ); +} diff --git a/siro_driver/lib/controller/home/captin/order_request_controller.dart b/siro_driver/lib/controller/home/captin/order_request_controller.dart index 55fc5c9c..1ca23b15 100755 --- a/siro_driver/lib/controller/home/captin/order_request_controller.dart +++ b/siro_driver/lib/controller/home/captin/order_request_controller.dart @@ -65,6 +65,21 @@ class OrderRequestController extends GetxController }; } + /// معلومات الحجز المسبق إن كانت الرحلة مؤجلة. + /// + /// يصل الحقلان من buildMarketPayload عبر السوكِت (خريطة)، أو من الفهرسين + /// 39/40 عبر FCM (قائمة). _getValueAt يوحّد الشكلين. + Map? 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 لتجنب الأخطاء القاتلة) diff --git a/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart b/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart index fb5def4c..929bd8ab 100755 --- a/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart +++ b/siro_driver/lib/views/home/Captin/orderCaptin/order_request_page.dart @@ -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), diff --git a/siro_driver/lib/views/notification/available_rides_page.dart b/siro_driver/lib/views/notification/available_rides_page.dart index 8d4da084..0199b6f3 100755 --- a/siro_driver/lib/views/notification/available_rides_page.dart +++ b/siro_driver/lib/views/notification/available_rides_page.dart @@ -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( diff --git a/siro_driver/lib/views/widgets/scheduled_ride_badge.dart b/siro_driver/lib/views/widgets/scheduled_ride_badge.dart new file mode 100644 index 00000000..29b2beb4 --- /dev/null +++ b/siro_driver/lib/views/widgets/scheduled_ride_badge.dart @@ -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, + ), + ), + ], + ), + ); + } +}