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