Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb9517826e | ||
|
|
e52afa1863 | ||
|
|
e63f990aea | ||
|
|
8c9f729ecd | ||
|
|
dd44b84e81 | ||
|
|
875ce763bd | ||
|
|
c659af67dc | ||
|
|
ef37752ceb | ||
|
|
b97a1fd587 | ||
|
|
8395436f23 | ||
|
|
c8e8e481a6 | ||
|
|
fae0e4a38a | ||
|
|
611b5e616d | ||
|
|
0db59647eb | ||
|
|
cdd36d4777 | ||
|
|
9338e76894 | ||
|
|
b2a95c53aa | ||
|
|
9e186f9ff3 | ||
|
|
d691e51ba4 | ||
|
|
92a6aa5c0f | ||
|
|
67465df000 | ||
|
|
7a932580a3 | ||
|
|
0b8b687ce1 | ||
|
|
b938e287c6 | ||
|
|
977d238c84 | ||
|
|
8e7c4a7104 | ||
|
|
f0ea698ab2 | ||
|
|
e070c2d9a8 | ||
|
|
70772cf0d2 | ||
|
|
3081aea802 | ||
|
|
b492034bad | ||
|
|
9830e5f649 | ||
|
|
5b864e30df | ||
|
|
0cb1ab0ea3 | ||
|
|
a1104a4695 | ||
|
|
6e451e8b71 | ||
|
|
4d64a205fa | ||
|
|
97471fd2bd | ||
|
|
52101e650c | ||
|
|
e4553dabc1 | ||
|
|
ca55aaebe8 | ||
|
|
d695834d19 | ||
|
|
788d3dcbe5 | ||
|
|
f01e408ba6 | ||
|
|
04c214bdf6 | ||
|
|
5f5e78691d | ||
|
|
dbf1e7870f | ||
|
|
79333a4bee | ||
|
|
b60129d246 | ||
|
|
f36b30f0b8 | ||
|
|
ec467b01c6 | ||
|
|
4abfd30ddd | ||
|
|
0334f9881f | ||
|
|
2b9f696372 | ||
|
|
c23c3c0721 | ||
|
|
5691d84fa0 | ||
|
|
2c962bf80a | ||
|
|
c9af364cbc | ||
|
|
02e652a786 | ||
|
|
03a21aa95d | ||
|
|
3b4b4639a6 |
@@ -0,0 +1,22 @@
|
||||
# سياق البناء لخدمات docker/docker-compose.yml هو جذر المستودع (context: ..).
|
||||
# بدون هذا الملف يُرسل .git كاملاً (مئات الميغابايت) إلى الـ daemon مع كل بناء،
|
||||
# وهو سبب أخطاء "Can't add file .git/objects/... to tar".
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
|
||||
# تطبيقات فلاتر — لا علاقة لها بأي صورة باك إند
|
||||
siro_rider
|
||||
siro_driver
|
||||
|
||||
# اعتماديات مُثبَّتة داخل الصور أو مركَّبة كـ volumes
|
||||
**/node_modules
|
||||
**/vendor
|
||||
|
||||
# وثائق ومخرجات محلية
|
||||
docs
|
||||
*.log
|
||||
*.tar
|
||||
*.zip
|
||||
@@ -101,3 +101,5 @@ GoogleService-Info.plist
|
||||
# --- Audit/Scan Output ---
|
||||
semgrep_*.json
|
||||
nuclei_results.txt
|
||||
|
||||
*.env.bak.*
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -29,7 +29,10 @@ class WorkerClient(private val context: Context) {
|
||||
// حتى يكون تبديله عند نسخ البوت لعلامة أخرى سطراً واحداً في gradle.
|
||||
// للتجريب المحلي: اضبط BACKEND_HOST على "http://10.0.2.2:8000" مع تقديم
|
||||
// نفس المسار /backend/bot/ من جذر السيرفر المحلي.
|
||||
private val BASE_URL = "${BuildConfig.BACKEND_HOST}/backend/bot/standalone_worker.php"
|
||||
// worker.php هو المسار الإنتاجي (Redis + MySQL). لا تُعِده إلى
|
||||
// standalone_worker.php: تلك نسخة اختبار محلي تخزّن في ملفات JSON
|
||||
// ولا تصل نتائجها إلى جدول scraped_competitor_prices ولا إلى محرّك التسعير.
|
||||
private val BASE_URL = "${BuildConfig.BACKEND_HOST}/backend/bot/worker.php"
|
||||
|
||||
private fun generateSignature(deviceId: String, ts: Long): String {
|
||||
val message = "$deviceId$ts"
|
||||
|
||||
@@ -33,15 +33,20 @@ SELECT
|
||||
-- كانت تُنتج متوسط مدة سالباً.
|
||||
(SELECT TIME_FORMAT(SEC_TO_TIME(AVG(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))), '%Hh %im') FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL AND TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish) > 0) AS driver_avg_duration,
|
||||
(SELECT MAX(SEC_TO_TIME(TIMESTAMPDIFF(SECOND, rideTimeStart, rideTimeFinish))) FROM ride WHERE rideTimeStart IS NOT NULL AND rideTimeFinish IS NOT NULL) AS longest_duration,
|
||||
(SELECT ROUND(SUM(distance),2) FROM ride) AS total_distance,
|
||||
(SELECT ROUND(AVG(distance),2) FROM ride) AS average_distance,
|
||||
(SELECT ROUND(MAX(distance),2) FROM ride) AS longest_distance,
|
||||
-- المسافات تُحسب على الرحلات المكتملة فقط. حسابها على كل الصفوف كان
|
||||
-- يُدخل الرحلات الملغاة و timeout (مسافتها 0 أو غير مضبوطة) في المتوسط
|
||||
-- فيهبط average_distance بشكل مضلّل.
|
||||
(SELECT ROUND(SUM(distance),2) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS total_distance,
|
||||
(SELECT ROUND(AVG(distance),2) FROM ride WHERE LOWER(status) IN ('finished','completed') AND distance > 0) AS average_distance,
|
||||
(SELECT ROUND(MAX(distance),2) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS longest_distance,
|
||||
|
||||
-- أرباح السائق والشركة
|
||||
-- ملاحظة: خط الرحلات الحالي يكتب 'completed' بينما القديم يكتب 'Finished'،
|
||||
-- والاكتفاء بالقديم كان يُرجع NULL للأرباح وصفراً للرحلات المكتملة/الملغاة.
|
||||
(SELECT SUM(price_for_driver) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS total_driver_earnings,
|
||||
(SELECT ROUND(AVG(price_for_passenger),2) FROM ride) AS avg_passenger_price,
|
||||
-- متوسط سعر الراكب على الرحلات المكتملة فقط — الرحلات الملغاة سعرها 0
|
||||
-- وكانت تسحب المتوسط إلى أسفل.
|
||||
(SELECT ROUND(AVG(price_for_passenger),2) FROM ride WHERE LOWER(status) IN ('finished','completed') AND price_for_passenger > 0) AS avg_passenger_price,
|
||||
|
||||
-- توزيع الرحلات حسب الوقت
|
||||
(SELECT COUNT(*) FROM ride WHERE HOUR(created_at) BETWEEN 6 AND 11) AS morning_ride_count,
|
||||
@@ -54,12 +59,20 @@ SELECT
|
||||
(SELECT COUNT(*) FROM ride WHERE carType = 'Lady') AS lady,
|
||||
|
||||
-- حالة الرحلات (تغطي عائلتي الحالات: القديمة CamelCase والجديدة lowercase)
|
||||
(SELECT COUNT(*) FROM ride WHERE LOWER(status) IN ('wait','waiting','new','nothing','pending','searching')) AS ongoing_rides,
|
||||
-- الحالات الفعلية في قاعدة الإنتاج (فحص 2026-08-03): nothing 930،
|
||||
-- Begin 624، start 170، waiting 2. كانت Begin و start خارج كل الدلاء
|
||||
-- الثلاثة، أي أن 794 رحلة (46% من الإجمالي) لا تظهر في أي إحصائية.
|
||||
-- 'جارية' تشمل المنتظرة للسائق والمنفَّذة فعلياً معاً.
|
||||
(SELECT COUNT(*) FROM ride WHERE LOWER(status) IN
|
||||
('wait','waiting','new','nothing','pending','searching',
|
||||
'begin','start','arrived','apply')) AS ongoing_rides,
|
||||
(SELECT COUNT(*) FROM ride WHERE LOWER(status) IN ('finished','completed')) AS completed_rides,
|
||||
(SELECT COUNT(*) FROM ride WHERE LOWER(status) LIKE 'cancel%' OR LOWER(status) IN ('timeout','refused')) AS cancelled_rides,
|
||||
|
||||
-- عدد السائقين الفريدين
|
||||
(SELECT COUNT(*) FROM (SELECT driver_id FROM ride GROUP BY driver_id) AS sub) AS num_Driver,
|
||||
-- cancel_ride_by_driver.php يضبط driver_id = 0 عند الإلغاء، وكان هذا
|
||||
-- الصفر يُحتسب سائقاً وهمياً إضافياً في العدد.
|
||||
(SELECT COUNT(DISTINCT driver_id) FROM ride WHERE driver_id > 0) AS num_Driver,
|
||||
|
||||
-- التحويلات البنكية
|
||||
0 AS transfer_from_count
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
// Admin/fuel/drawdowns.php — حركة القسائم وملخّص الائتمان
|
||||
// POST (اختياري): station_id, status, driver_id, limit
|
||||
//
|
||||
// الشاشة التي تُجيب: كم أقرضنا؟ كم استُرد؟ وأي محطة تعمل؟
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
|
||||
$status = filterRequest('status');
|
||||
$driverId = filterRequest('driver_id');
|
||||
$limit = (int) (filterRequest('limit', 'int') ?: 100);
|
||||
$limit = max(1, min($limit, 500));
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($stationId > 0) {
|
||||
$where[] = 'v.station_id = ?';
|
||||
$params[] = $stationId;
|
||||
}
|
||||
if (!empty($status)) {
|
||||
$where[] = 'v.status = ?';
|
||||
$params[] = $status;
|
||||
}
|
||||
if (!empty($driverId)) {
|
||||
$where[] = 'v.driver_id = ?';
|
||||
$params[] = $driverId;
|
||||
}
|
||||
|
||||
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
SELECT v.id, v.driver_id, v.code, v.amount_authorized, v.amount_redeemed,
|
||||
v.currency, v.status, v.expires_at, v.redeemed_at, v.created_at,
|
||||
s.name_ar AS station_name
|
||||
FROM fuel_vouchers v
|
||||
LEFT JOIN fuel_stations s ON s.id = v.station_id
|
||||
$clause
|
||||
ORDER BY v.id DESC
|
||||
LIMIT $limit
|
||||
");
|
||||
$st->execute($params);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// الملخّص على كامل المنتج لا على الصفحة المعروضة: رقمٌ يتغيّر بتغيّر
|
||||
// الفلتر يُقرأ كأنه حقيقة عن المنصة وهو ليس كذلك.
|
||||
$summary = $con->query("
|
||||
SELECT
|
||||
COALESCE(SUM(amount), 0) AS lent_total,
|
||||
COALESCE(SUM(amount_collected), 0) AS collected_total,
|
||||
COALESCE(SUM(amount_remaining), 0) AS outstanding_total,
|
||||
COUNT(DISTINCT driver_id) AS drivers_count
|
||||
FROM obligation_ledger
|
||||
WHERE product_code = 'fuel_wallet'
|
||||
")->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
} catch (PDOException $e) {
|
||||
error_log('[admin/fuel/drawdowns] ' . $e->getMessage());
|
||||
jsonError('Server error', 500);
|
||||
}
|
||||
|
||||
$vouchers = array_map(static fn(array $r): array => [
|
||||
'id' => (int) $r['id'],
|
||||
'driver_id' => $r['driver_id'],
|
||||
'code' => $r['code'],
|
||||
'amount_authorized' => (float) $r['amount_authorized'],
|
||||
'amount_redeemed' => (float) $r['amount_redeemed'],
|
||||
'currency' => $r['currency'],
|
||||
'status' => $r['status'],
|
||||
'station' => $r['station_name'],
|
||||
'expires_at' => $r['expires_at'],
|
||||
'redeemed_at' => $r['redeemed_at'],
|
||||
'created_at' => $r['created_at'],
|
||||
], $rows);
|
||||
|
||||
jsonSuccess([
|
||||
'vouchers' => $vouchers,
|
||||
'summary' => [
|
||||
'lent_total' => (float) ($summary['lent_total'] ?? 0),
|
||||
'collected_total' => (float) ($summary['collected_total'] ?? 0),
|
||||
'outstanding_total' => (float) ($summary['outstanding_total'] ?? 0),
|
||||
'drivers_count' => (int) ($summary['drivers_count'] ?? 0),
|
||||
],
|
||||
], 'success');
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
// Admin/fuel/list.php — قائمة محطات الوقود الشريكة
|
||||
// POST (اختياري): search, city, only_active
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
$search = filterRequest('search');
|
||||
$city = filterRequest('city');
|
||||
$onlyActive = filterRequest('only_active') === '1';
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!empty($search)) {
|
||||
$where[] = '(s.name_ar LIKE ? OR s.chain_name LIKE ? OR s.address LIKE ?)';
|
||||
$like = '%' . $search . '%';
|
||||
array_push($params, $like, $like, $like);
|
||||
}
|
||||
if (!empty($city)) {
|
||||
$where[] = 's.city = ?';
|
||||
$params[] = $city;
|
||||
}
|
||||
if ($onlyActive) {
|
||||
$where[] = 's.is_active = 1';
|
||||
}
|
||||
|
||||
$clause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
try {
|
||||
// إحصاء الصرف مع كل محطة: لوحة بلا أرقام لا تخبر أحداً أي محطة
|
||||
// تعمل فعلاً وأيها اسمٌ في جدول. `redeemed` وحدها — القسائم الملغاة
|
||||
// والمنتهية لا تقول شيئاً عن أداء المحطة.
|
||||
$st = $con->prepare("
|
||||
SELECT s.*,
|
||||
COUNT(v.id) AS redeem_count,
|
||||
COALESCE(SUM(v.amount_redeemed), 0) AS redeem_total,
|
||||
MAX(v.redeemed_at) AS last_redeem_at
|
||||
FROM fuel_stations s
|
||||
LEFT JOIN fuel_vouchers v
|
||||
ON v.station_id = s.id AND v.status = 'redeemed'
|
||||
$clause
|
||||
GROUP BY s.id
|
||||
ORDER BY s.is_active DESC, s.city ASC, s.name_ar ASC
|
||||
LIMIT 200
|
||||
");
|
||||
$st->execute($params);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[admin/fuel/list] ' . $e->getMessage());
|
||||
jsonError('Server error', 500);
|
||||
}
|
||||
|
||||
$stations = array_map(static fn(array $r): array => [
|
||||
'id' => (int) $r['id'],
|
||||
'name_ar' => $r['name_ar'],
|
||||
'chain_name' => $r['chain_name'],
|
||||
'city' => $r['city'],
|
||||
'address' => $r['address'],
|
||||
'lat' => $r['lat'] !== null ? (float) $r['lat'] : null,
|
||||
'lng' => $r['lng'] !== null ? (float) $r['lng'] : null,
|
||||
'phone' => $r['phone'],
|
||||
'discount_percent' => (float) $r['discount_percent'],
|
||||
'is_active' => (int) $r['is_active'] === 1,
|
||||
'created_at' => $r['created_at'],
|
||||
'redeem_count' => (int) $r['redeem_count'],
|
||||
'redeem_total' => (float) $r['redeem_total'],
|
||||
'last_redeem_at' => $r['last_redeem_at'],
|
||||
// المفتاح لا يخرج أبداً — لا هنا ولا في أي نقطة أخرى. مخزَّن مُهشَّراً
|
||||
// أصلاً، ويُعرض مرة واحدة فقط لحظة إنشائه أو تدويره.
|
||||
], $rows);
|
||||
|
||||
// حالة المنتج تُرسل مع القائمة لا في نقطة منفصلة: الشاشة تحتاج أن تعرض
|
||||
// «مُجهَّز وغير مُشغَّل» بوضوح، وطلبان لبناء لافتة واحدة إسراف.
|
||||
$productActive = false;
|
||||
try {
|
||||
$st = $con->prepare("SELECT is_active FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
|
||||
$st->execute();
|
||||
$productActive = (int) $st->fetchColumn() === 1;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[admin/fuel/list] تعذّرت قراءة حالة المنتج: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'stations' => $stations,
|
||||
'product_active' => $productActive,
|
||||
], 'success');
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
// Admin/fuel/rotate_key.php — توليد مفتاح جديد لمحطة
|
||||
// POST: station_id
|
||||
//
|
||||
// يُستعمل عند فقدان المحطة مفتاحها أو الشك في تسريبه. المفتاح القديم
|
||||
// يتوقّف فوراً — وهذا مقصود: التدوير إجراء أمني، وإبقاء القديم يعمل
|
||||
// «حتى تنتقل المحطة» يُفرغه من معناه.
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
|
||||
if ($stationId <= 0) {
|
||||
jsonError('station_id مطلوب');
|
||||
}
|
||||
|
||||
try {
|
||||
$st = $con->prepare("SELECT name_ar FROM fuel_stations WHERE id = ? LIMIT 1");
|
||||
$st->execute([$stationId]);
|
||||
$name = $st->fetchColumn();
|
||||
|
||||
if ($name === false) {
|
||||
jsonError('المحطة غير موجودة', 404);
|
||||
}
|
||||
|
||||
$plainKey = bin2hex(random_bytes(24));
|
||||
|
||||
$con->prepare("UPDATE fuel_stations SET api_key_hash = ? WHERE id = ?")
|
||||
->execute([hash('sha256', $plainKey), $stationId]);
|
||||
|
||||
error_log("[admin/fuel] دُوِّر مفتاح المحطة #$stationId بواسطة $user_id");
|
||||
|
||||
jsonSuccess([
|
||||
'station_id' => $stationId,
|
||||
'station' => $name,
|
||||
'api_key' => $plainKey,
|
||||
], 'المفتاح القديم توقّف. انسخ الجديد الآن — لن يظهر مرة أخرى.');
|
||||
} catch (PDOException $e) {
|
||||
error_log('[admin/fuel/rotate_key] ' . $e->getMessage());
|
||||
jsonError('Server error', 500);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
// Admin/fuel/save.php — إضافة محطة أو تعديلها
|
||||
// POST: station_id (اختياري للتعديل), name_ar, chain_name, city, address,
|
||||
// lat, lng, phone, discount_percent, is_active
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Admin access required', 403);
|
||||
}
|
||||
|
||||
$stationId = (int) (filterRequest('station_id', 'int') ?: 0);
|
||||
$nameAr = filterRequest('name_ar');
|
||||
|
||||
if (empty($nameAr)) {
|
||||
jsonError('اسم المحطة مطلوب');
|
||||
}
|
||||
|
||||
$discount = (float) (filterRequest('discount_percent', 'numeric') ?: 0);
|
||||
if ($discount < 0 || $discount > 100) {
|
||||
jsonError('نسبة الخصم يجب أن تكون بين 0 و 100');
|
||||
}
|
||||
|
||||
$lat = filterRequest('lat', 'numeric');
|
||||
$lng = filterRequest('lng', 'numeric');
|
||||
|
||||
$fields = [
|
||||
'name_ar' => $nameAr,
|
||||
'chain_name' => filterRequest('chain_name'),
|
||||
'city' => filterRequest('city'),
|
||||
'address' => filterRequest('address'),
|
||||
'lat' => $lat !== null ? (float) $lat : null,
|
||||
'lng' => $lng !== null ? (float) $lng : null,
|
||||
'phone' => filterRequest('phone'),
|
||||
'discount_percent' => $discount,
|
||||
'is_active' => filterRequest('is_active') === '0' ? 0 : 1,
|
||||
];
|
||||
|
||||
try {
|
||||
if ($stationId > 0) {
|
||||
$sets = implode(', ', array_map(static fn($k) => "`$k` = ?", array_keys($fields)));
|
||||
|
||||
$st = $con->prepare("UPDATE fuel_stations SET $sets WHERE id = ?");
|
||||
$st->execute([...array_values($fields), $stationId]);
|
||||
|
||||
if ($st->rowCount() === 0) {
|
||||
// rowCount صفر قد يعني «غير موجودة» وقد يعني «حُفظت بلا تغيير».
|
||||
// نفرّق بينهما بقراءة صريحة: إخبار المستخدم أن المحطة غير
|
||||
// موجودة بينما هي موجودة يدفعه لإنشاء نسخة ثانية منها.
|
||||
$chk = $con->prepare("SELECT 1 FROM fuel_stations WHERE id = ? LIMIT 1");
|
||||
$chk->execute([$stationId]);
|
||||
if (!$chk->fetchColumn()) {
|
||||
jsonError('المحطة غير موجودة', 404);
|
||||
}
|
||||
}
|
||||
|
||||
error_log("[admin/fuel] عُدّلت المحطة #$stationId بواسطة $user_id");
|
||||
|
||||
jsonSuccess(['station_id' => $stationId, 'created' => false], 'تم حفظ التعديلات');
|
||||
}
|
||||
|
||||
// ── إنشاء جديد ──
|
||||
// المفتاح يُولَّد هنا ولا يُطلب من المستخدم: مفتاح يختاره إنسان يكون
|
||||
// قصيراً ومتوقَّعاً، وهذا المفتاح يأذن بصرف مال.
|
||||
$plainKey = bin2hex(random_bytes(24));
|
||||
|
||||
$cols = array_keys($fields);
|
||||
$st = $con->prepare(
|
||||
'INSERT INTO fuel_stations (`' . implode('`, `', $cols) . '`, `api_key_hash`) '
|
||||
. 'VALUES (' . implode(', ', array_fill(0, count($cols), '?')) . ', ?)'
|
||||
);
|
||||
$st->execute([...array_values($fields), hash('sha256', $plainKey)]);
|
||||
|
||||
$stationId = (int) $con->lastInsertId();
|
||||
|
||||
error_log("[admin/fuel] أُنشئت المحطة #$stationId بواسطة $user_id");
|
||||
|
||||
jsonSuccess([
|
||||
'station_id' => $stationId,
|
||||
'created' => true,
|
||||
// المرة **الوحيدة** التي يظهر فيها المفتاح. مخزَّن مُهشَّراً، فلا
|
||||
// سبيل لاستعادته لاحقاً — من يفقده يدوّر مفتاحاً جديداً.
|
||||
'api_key' => $plainKey,
|
||||
], 'أُنشئت المحطة. انسخ المفتاح الآن — لن يظهر مرة أخرى.');
|
||||
} catch (PDOException $e) {
|
||||
error_log('[admin/fuel/save] ' . $e->getMessage());
|
||||
jsonError('Server error', 500);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
// Admin/fuel/toggle_product.php — تشغيل محفظة الوقود أو إيقافها
|
||||
// POST: is_active (1 | 0)
|
||||
//
|
||||
// قرار المالك: تُبنى الشاشات وتُجهَّز الأمور، ولا يُشغَّل المنتج إلا بعد
|
||||
// إتمام الاتفاق مع سلسلة المحطات. هذه هي نقطة ذلك التبديل.
|
||||
//
|
||||
// ما الذي يتغيّر فعلاً؟ المنتج المُطفأ لا يظهر في `obligationProduct`،
|
||||
// فلا يستطيع سائق تفعيل محفظته ولا إصدار قسيمة. أما الالتزامات القائمة
|
||||
// ودفترها فلا تتأثر — الإطفاء يغلق الباب ولا يمحو ما خلفه.
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// super_admin وحده. تشغيل المنتج يفتح باب إقراض حقيقي بمال الشركة،
|
||||
// وهو قرار مالك لا قرار مناوب على اللوحة.
|
||||
if ($role !== 'super_admin') {
|
||||
jsonError('Unauthorized: Super admin access required', 403);
|
||||
}
|
||||
|
||||
$isActive = filterRequest('is_active') === '1' ? 1 : 0;
|
||||
|
||||
try {
|
||||
// التشغيل بلا محطة واحدة فعّالة يعني سائقاً يفعّل محفظته، يطلب
|
||||
// قسيمة، ثم لا يجد أين يصرفها. الرفض هنا أرحم من اكتشافه عند مضخة.
|
||||
if ($isActive === 1) {
|
||||
$st = $con->query("SELECT COUNT(*) FROM fuel_stations WHERE is_active = 1");
|
||||
if ((int) $st->fetchColumn() === 0) {
|
||||
jsonError('لا توجد محطة فعّالة واحدة. أضف محطة قبل تشغيل المنتج.');
|
||||
}
|
||||
}
|
||||
|
||||
$st = $con->prepare("UPDATE obligation_products SET is_active = ? WHERE code = 'fuel_wallet'");
|
||||
$st->execute([$isActive]);
|
||||
|
||||
if ($st->rowCount() === 0) {
|
||||
$chk = $con->query("SELECT 1 FROM obligation_products WHERE code = 'fuel_wallet' LIMIT 1");
|
||||
if (!$chk->fetchColumn()) {
|
||||
jsonError('منتج محفظة الوقود غير موجود — نفّذ الترحيل أولاً', 404);
|
||||
}
|
||||
}
|
||||
|
||||
error_log('[admin/fuel] ' . ($isActive ? 'شُغّلت' : 'أُوقفت') . " محفظة الوقود بواسطة $user_id");
|
||||
|
||||
jsonSuccess(
|
||||
['product_active' => $isActive === 1],
|
||||
$isActive === 1
|
||||
? 'شُغّلت محفظة الوقود — صارت متاحة للسائقين المؤهَّلين'
|
||||
: 'أُوقفت محفظة الوقود — لا تفعيل جديد ولا قسائم جديدة'
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[admin/fuel/toggle_product] ' . $e->getMessage());
|
||||
jsonError('Server error', 500);
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// هذه النقطة تُرجع بيانات ركّاب مفكوكة التشفير (هاتف، بريد، تاريخ ميلاد،
|
||||
// هاتف طوارئ، وتوكن إشعارات). لم يكن فيها أي فحص للدور، و connect.php لا
|
||||
// يفرض بوّابة عامة على مسار /Admin — أي أن أي مستخدم يملك JWT صالحاً، بما
|
||||
// فيه راكب عادي، كان يستطيع سحب هذه البيانات كاملة.
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Admin access required.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
`passengers`.`id`,
|
||||
`passengers`.`phone`,
|
||||
@@ -91,6 +104,21 @@ foreach ($result as &$row) {
|
||||
}
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
// نموذج الصلاحيات: `super_admin` وحده يرى الرقم كاملاً، و`admin` يراقب برقم
|
||||
// مُخفى جزئياً. توكن الإشعارات لا تعرضه الواجهة إطلاقاً ولا حاجة لإرساله —
|
||||
// تسريبه يتيح انتحال إشعارات موجّهة لراكب بعينه.
|
||||
$isSuperAdmin = ($role === 'super_admin');
|
||||
foreach ($result as &$row) {
|
||||
unset($row['passengerToken']);
|
||||
|
||||
if (!$isSuperAdmin) {
|
||||
if (isset($row['phone'])) $row['phone'] = maskPhone($row['phone']);
|
||||
if (isset($row['sosPhone'])) $row['sosPhone'] = maskPhone($row['sosPhone']);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
jsonSuccess($data = $result);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// نقطة تُرجع بيانات راكب مفكوكة التشفير. connect.php لا يفرض بوّابة عامة
|
||||
// على مسار /Admin، فبدون هذا الفحص كان أي مستخدم يملك JWT صالحاً — بما فيه
|
||||
// راكب عادي — يستطيع استعلام بيانات أي راكب آخر.
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Admin access required.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$passengerID = filterRequest("passengerID");
|
||||
|
||||
$sql = "SELECT
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// نقطة تُرجع بيانات راكب مفكوكة التشفير. connect.php لا يفرض بوّابة عامة
|
||||
// على مسار /Admin، فبدون هذا الفحص كان أي مستخدم يملك JWT صالحاً — بما فيه
|
||||
// راكب عادي — يستطيع استعلام بيانات أي راكب آخر.
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Admin access required.',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$passengerEmail = $encryptionHelper->encryptData(filterRequest("passengerEmail"));
|
||||
$passengerId = filterRequest("passengerId");
|
||||
$passengerphone = $encryptionHelper->encryptData(filterRequest("passengerphone"));
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// Admin/marketing/get_social_reports.php
|
||||
// تقارير الذكاء الاجتماعي للوحة التحكم
|
||||
// ============================================================
|
||||
//
|
||||
// لماذا نقطة جديدة بدل استعمال marketing_engine/index.php?action=get_reports:
|
||||
//
|
||||
// • تلك نقطة **البوت** لا نقطة إدارية: تُصادَق بترويسة X-Bot-Token
|
||||
// المخبوزة في APK، وتخدم طابور المهام. استعمالها من اللوحة يعني إما
|
||||
// تسريب توكن البوت إلى المتصفح، أو إبقاء النقطة مفتوحة للجميع —
|
||||
// وقد كانت مفتوحة فعلاً حتى فُعّلت المصادقة، فصارت تُرجع 401 للّوحة.
|
||||
//
|
||||
// • الشاشة كانت تستدعيها بـ http.get الخام بلا JWT ولا X-Device-FP،
|
||||
// فلا تظهر في سجل CRUD ولا تخضع لأي تحقّق صلاحيات.
|
||||
//
|
||||
// هذه النقطة تحت /Admin فتغطّيها البوّابة الافتراضية في connect.php.
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
try {
|
||||
$limit = (int) (filterRequest('limit', 'int') ?? 50);
|
||||
$limit = max(1, min(200, $limit));
|
||||
|
||||
// is_weekly=1 لتقارير الأسبوع فقط، أو 0 للحظية، أو تُترك فارغة للكل
|
||||
$weeklyOnly = filterRequest('weekly');
|
||||
|
||||
$where = '';
|
||||
$params = [];
|
||||
if ($weeklyOnly !== null && $weeklyOnly !== '') {
|
||||
$where = 'WHERE is_weekly = :weekly';
|
||||
$params[':weekly'] = ((string) $weeklyOnly === '1') ? 1 : 0;
|
||||
}
|
||||
|
||||
$stmt = $con->prepare("
|
||||
SELECT id, platform, report_html, is_weekly, created_at
|
||||
FROM marketing_reports
|
||||
$where
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $limit
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$reports = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($reports as &$r) {
|
||||
$r['id'] = (int) $r['id'];
|
||||
$r['is_weekly'] = (int) $r['is_weekly'] === 1;
|
||||
}
|
||||
unset($r);
|
||||
|
||||
jsonSuccess($reports);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
// الجدول قد يكون غير موجود على نشر لم يُطبَّق عليه الترحيل
|
||||
// 2026_08_03_create_marketing_engine_tables.sql — نُبلغ بوضوح بدل
|
||||
// إرجاع خطأ عام يُقرأ كـ "لا بيانات".
|
||||
error_log('[get_social_reports] ' . $e->getMessage());
|
||||
jsonError('تعذّر جلب تقارير الذكاء الاجتماعي. راجع سجل الخادم.', 500);
|
||||
}
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
require_once __DIR__ . '/../../connect.php'; // تأكد أن هذا الملف يحتوي على $con_tracking
|
||||
|
||||
// $con_tracking لا يعرّفه connect.php — كان الاستدعاء يسقط بـ
|
||||
// "Call to a member function prepare() on null".
|
||||
$con_tracking = Database::get('tracking');
|
||||
|
||||
|
||||
header("Content-Type: application/json; charset=UTF-8");
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// $con_tracking لا يعرّفه connect.php — كان الاستدعاء يسقط بـ
|
||||
// "Call to a member function prepare() on null".
|
||||
$con_tracking = Database::get('tracking');
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
http_response_code(403);
|
||||
echo json_encode(['error' => 'Unauthorized: Admin access required']);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// safety/list_incidents.php — قائمة بلاغات السلامة للوحة الإدارة
|
||||
//
|
||||
// قراءة خالصة. الترتيب: المفتوح أولاً ثم الأحدث — غرفة العمليات تحتاج
|
||||
// ما لم يُعالَج بعد، لا أحدث ما وقع.
|
||||
// ============================================================
|
||||
|
||||
// تحت Admin/ عمداً: بوّابة connect.php المسارية تفرض دور admin أو
|
||||
// super_admin على كل ما تحت هذا المجلد. لو بقي في safety/ لقرأه أي حامل
|
||||
// JWT صالح — بما فيه راكب — وفيه معرّفات ومواقع أطراف الحادثة.
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$status = filterRequest("status"); // open | acknowledged | resolved | false_alarm | (فارغ = الكل)
|
||||
$limit = (int) (filterRequest("limit", 'int') ?: 50);
|
||||
$limit = max(1, min(200, $limit));
|
||||
|
||||
$allowed = ['open', 'acknowledged', 'resolved', 'false_alarm'];
|
||||
|
||||
try {
|
||||
if ($status && in_array($status, $allowed, true)) {
|
||||
$stmt = $con->prepare("
|
||||
SELECT * FROM safety_incidents
|
||||
WHERE status = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $limit
|
||||
");
|
||||
$stmt->execute([$status]);
|
||||
} else {
|
||||
// FIELD() يضع 'open' أولاً مهما كان تاريخه — بلاغ مفتوح من أمس
|
||||
// أهم من بلاغ عولج قبل دقيقة.
|
||||
$stmt = $con->query("
|
||||
SELECT * FROM safety_incidents
|
||||
ORDER BY FIELD(status, 'open', 'acknowledged', 'resolved', 'false_alarm'),
|
||||
created_at DESC
|
||||
LIMIT $limit
|
||||
");
|
||||
}
|
||||
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// عدّاد المفتوح — الرقم الذي يظهر كشارة على اللوحة
|
||||
$openCount = (int) $con->query(
|
||||
"SELECT COUNT(*) FROM safety_incidents WHERE status = 'open'"
|
||||
)->fetchColumn();
|
||||
|
||||
jsonSuccess(['open_count' => $openCount, 'incidents' => $rows], "ok");
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[safety/list] " . $e->getMessage());
|
||||
jsonError("DB Error", 500);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// Admin/safety/update_incident.php — معالجة بلاغ سلامة
|
||||
//
|
||||
// يحوّل البلاغ من "مفتوح" إلى حالة نهائية، ويرفع تجميد السائق عند
|
||||
// الإغلاق. بدون هذه النقطة يبقى السائق مجمّداً إلى الأبد — التجميد بلا
|
||||
// TTL عمداً، فرفعه قرار إنسان لا انقضاء وقت.
|
||||
//
|
||||
// تحت Admin/ فترث بوّابة الدور من connect.php.
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$incidentId = filterRequest("incident_id", 'int');
|
||||
$newStatus = filterRequest("status");
|
||||
$note = filterRequest("note");
|
||||
$releaseHold = filterRequest("release_hold");
|
||||
|
||||
$allowed = ['acknowledged', 'resolved', 'false_alarm'];
|
||||
|
||||
if (!$incidentId || !in_array($newStatus, $allowed, true)) {
|
||||
jsonError("Missing incident_id or invalid status");
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare("SELECT * FROM safety_incidents WHERE id = ? LIMIT 1");
|
||||
$stmt->execute([$incidentId]);
|
||||
$incident = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$incident) {
|
||||
jsonError("Incident not found", 404);
|
||||
}
|
||||
|
||||
$con->prepare("
|
||||
UPDATE safety_incidents
|
||||
SET status = ?, acknowledged_by = ?, acknowledged_at = NOW(), resolution_note = ?
|
||||
WHERE id = ?
|
||||
")->execute([
|
||||
$newStatus,
|
||||
$user_id ?? 'admin',
|
||||
$note ? mb_substr($note, 0, 500) : null,
|
||||
$incidentId,
|
||||
]);
|
||||
|
||||
// ── رفع التجميد ─────────────────────────────────────────
|
||||
// افتراضياً يُرفع عند الإغلاق (resolved / false_alarm) ولا يُرفع عند
|
||||
// مجرد الاطلاع (acknowledged). ويمكن للمراجع أن يقرر صراحةً.
|
||||
$shouldRelease = $releaseHold !== null
|
||||
? in_array(strtolower((string) $releaseHold), ['1', 'true', 'yes'], true)
|
||||
: in_array($newStatus, ['resolved', 'false_alarm'], true);
|
||||
|
||||
$released = false;
|
||||
$driverId = $incident['driver_id'] ?? null;
|
||||
|
||||
if ($shouldRelease && $driverId) {
|
||||
try {
|
||||
// نرفع فقط إن كان التجميد من هذا البلاغ تحديداً: سائق عليه
|
||||
// بلاغ ثانٍ أحدث يجب ألا يُفرَج عنه بإغلاق الأقدم.
|
||||
$upd = $con->prepare("
|
||||
UPDATE driver SET safetyHoldIncidentId = NULL
|
||||
WHERE id = ? AND safetyHoldIncidentId = ?
|
||||
");
|
||||
$upd->execute([$driverId, $incidentId]);
|
||||
|
||||
if ($upd->rowCount() > 0) {
|
||||
if (isset($redisLocation) && $redisLocation) {
|
||||
$redisLocation->del("driver:$driverId:safety_hold");
|
||||
}
|
||||
$released = true;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("[safety/update] تعذّر رفع تجميد السائق $driverId: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
error_log("[safety] بلاغ #$incidentId → $newStatus بواسطة "
|
||||
. ($user_id ?? 'admin') . ($released ? " (رُفع التجميد)" : ""));
|
||||
|
||||
jsonSuccess([
|
||||
'incident_id' => $incidentId,
|
||||
'status' => $newStatus,
|
||||
'hold_released' => $released,
|
||||
], "Incident updated");
|
||||
|
||||
} catch (PDOException $e) {
|
||||
error_log("[safety/update] " . $e->getMessage());
|
||||
jsonError("DB Error", 500);
|
||||
}
|
||||
@@ -2,7 +2,14 @@
|
||||
// Admin/v2/quality/blacklist_manager.php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
require_once __DIR__ . '/../../../encrypt_decrypt.php';
|
||||
require_once __DIR__ . '/../security/audit_logs_helper.php'; // إذا كان متاحاً، وإلا سننفذ الإدخال مباشرة
|
||||
// audit_logs_helper.php لم يوجد قط في المستودع، والتعليق الأصلي كان
|
||||
// "إذا كان متاحاً، وإلا سننفذ الإدخال مباشرة" — لكن require_once قاتل
|
||||
// لا اختياري، فكانت الشاشة تسقط دائماً. الملف اختياري فعلاً: هذا الملف
|
||||
// يكتب في admin_audit_log مباشرةً أدناه.
|
||||
$auditHelper = __DIR__ . '/../security/audit_logs_helper.php';
|
||||
if (file_exists($auditHelper)) {
|
||||
require_once $auditHelper;
|
||||
}
|
||||
|
||||
if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
jsonError("Unauthorized", 403);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<?php
|
||||
// Admin/v2/security/audit_logs.php
|
||||
|
||||
// ── سجل تتبع ────────────────────────────────────────────
|
||||
$debugFile = __DIR__ . '/../../../logs/audit_debug.txt';
|
||||
$logDir = dirname($debugFile);
|
||||
if (!is_dir($logDir)) @mkdir($logDir, 0750, true);
|
||||
|
||||
@file_put_contents($debugFile, "[" . date('Y-m-d H:i:s') . "] === REQUEST START ===\n", FILE_APPEND);
|
||||
// كان هنا تتبّع تشخيصي يكتب ملفاً نصياً على القرص مع كل طلب، ويسجّل فيه
|
||||
// user_id والدور. أُزيل: يراكم ملفاً بلا حد في الإنتاج ويسرّب سياق الجلسة.
|
||||
// ما يستحق التسجيل يذهب إلى error_log مع الأخطاء وحدها.
|
||||
|
||||
try {
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
@file_put_contents($debugFile, " → connect.php & encryption OK. user_id=$user_id | role=$role\n", FILE_APPEND);
|
||||
} catch (Exception $e) {
|
||||
@file_put_contents($debugFile, " → Loading FAILED: " . $e->getMessage() . "\n", FILE_APPEND);
|
||||
error_log('[audit_logs] bootstrap failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
printFailure('loading failed', 500);
|
||||
exit;
|
||||
@@ -21,7 +17,6 @@ try {
|
||||
|
||||
// ── فحص الصلاحيات ────────────────────────────────────────
|
||||
if ($role !== 'super_admin' && $role !== 'admin') {
|
||||
@file_put_contents($debugFile, " → BLOCKED: role=$role\n", FILE_APPEND);
|
||||
printFailure("Unauthorized. role=$role", 403);
|
||||
}
|
||||
|
||||
@@ -55,12 +50,11 @@ try {
|
||||
}
|
||||
|
||||
$count = count($logs);
|
||||
@file_put_contents($debugFile, " → SUCCESS: fetched $count logs\n", FILE_APPEND);
|
||||
|
||||
jsonSuccess($logs);
|
||||
|
||||
} catch (Exception $e) {
|
||||
@file_put_contents($debugFile, " → QUERY ERROR: " . $e->getMessage() . "\n", FILE_APPEND);
|
||||
error_log('[audit_logs] query failed: ' . $e->getMessage());
|
||||
jsonError('Query failed', 500);
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// 🔥 [Fix Broken Access Control] كان يتحقق من صلاحية التوكن فقط — أي مستخدم
|
||||
// مسجّل دخول (راكب/سائق آخر) كان يقدر يجلب بيانات أي سائق مفكوكة التشفير
|
||||
@@ -10,7 +10,15 @@ if ($role !== 'admin' && $role !== 'super_admin') {
|
||||
exit;
|
||||
}
|
||||
|
||||
$driverId = filterRequest("id");
|
||||
// filterRequest() تقرأ من POST أو JSON body فقط ولا تلمس $_GET
|
||||
// (core/helpers.php). شاشة مراجعة الوثائق تنادي هذه النقطة بـ
|
||||
// ?id=... في الـ query string، فكانت تُرجع "driver_id is required"
|
||||
// دائماً رغم إرسال المعرّف. نقبل الشكلين، ونقبل driver_id أيضاً
|
||||
// لأن رسالة الخطأ نفسها تسمّيه هكذا.
|
||||
$driverId = filterRequest("id")
|
||||
?: filterRequest("driver_id")
|
||||
?: (isset($_GET['id']) ? trim((string) $_GET['id']) : null)
|
||||
?: (isset($_GET['driver_id']) ? trim((string) $_GET['driver_id']) : null);
|
||||
|
||||
if (empty($driverId)) {
|
||||
jsonError("driver_id is required.");
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
// المسار كان '/../../../connect.php' فيُحلّ من backend/auth/driver إلى
|
||||
// /var/www/connect.php — مستوى واحد فوق جذر الباك إند — فتسقط الشاشة بخطأ
|
||||
// قاتل رغم أن الاستجابة تُرجع 200.
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
// 🔥 [Fix Broken Access Control] كان يتحقق من صلاحية التوكن فقط بدون التحقق
|
||||
// من الدور — أي توكن صالح (حتى راكب) كان يقدر يسحب قائمة السائقين المعلّقين
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
|
||||
$phoneNumber = filterRequest("phone_number");
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
//register_driver_and_car.php
|
||||
$allowRegistration = true;
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Rate Limiting: الحماية من التسجيل العشوائي وهجمات الـ Bots
|
||||
@@ -128,7 +128,7 @@ try {
|
||||
// ======================================================
|
||||
// Step 1.5: التحقق الفعلي من ملكية رقم الهاتف قبل إكمال المعالجة (سد الثغرة)
|
||||
// ======================================================
|
||||
require_once __DIR__ . '/../../../core/Auth/EncryptionHelper.php';
|
||||
require_once __DIR__ . '/../../core/Security/EncryptionHelper.php';
|
||||
$tempEncryptionHelper = new EncryptionHelper($redis);
|
||||
$phoneNumber_encrypted_check = $tempEncryptionHelper->encryptData($data['phone']);
|
||||
|
||||
|
||||
@@ -147,7 +147,70 @@ try {
|
||||
$stmtUpd = $con->prepare($sqlUpdate);
|
||||
$stmtUpd->bindParam(':id', $matchedRowId, PDO::PARAM_INT);
|
||||
$stmtUpd->execute();
|
||||
jsonSuccess(null, "Your phone number has been verified.");
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// تغيير الجهاز: التحقق وحده لا يكفي. لا بد من ربط بصمة
|
||||
// الجهاز الجديد ثم إصدار توكن driver — وإلا بقي التطبيق
|
||||
// على توكن التسجيل ورجع كل endpoint محمي 403، وهو ما كان
|
||||
// يجعل شاشة OTP "تنجح" ثم لا يعمل شيء بعدها.
|
||||
// ═══════════════════════════════════════════════════════
|
||||
$chk = $con->prepare("SELECT id FROM driver WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)");
|
||||
global $blindIndex;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone_number) : null;
|
||||
$chk->execute([$encryptionHelper->encryptData($phone_number), $phoneBidx, $phoneBidx]);
|
||||
$driverRow = $chk->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$driverRow) {
|
||||
jsonSuccess(null, "Your phone number has been verified.");
|
||||
exit;
|
||||
}
|
||||
|
||||
$driverID = (string)$driverRow['id'];
|
||||
|
||||
$fpRaw = filterRequest("fingerprint") ?? filterRequest("fingerPrint") ?? ($_SERVER['HTTP_X_DEVICE_FP'] ?? null);
|
||||
if ($fpRaw === null && function_exists('getallheaders')) {
|
||||
$hdrs = array_change_key_case(getallheaders(), CASE_LOWER);
|
||||
$fpRaw = $hdrs['x-device-fp'] ?? null;
|
||||
}
|
||||
|
||||
if (empty($fpRaw)) {
|
||||
jsonError("Device fingerprint is required to complete device change.");
|
||||
exit;
|
||||
}
|
||||
|
||||
$fpPepper = getenv('FP_PEPPER') ?: '';
|
||||
$fpHashed = !empty($fpPepper) ? hash('sha256', $fpRaw . $fpPepper) : $fpRaw;
|
||||
$fcmToken = filterRequest("token");
|
||||
|
||||
// ربط الجهاز الجديد — نفس الصيغة التي يتوقعها loginJwtDriver.
|
||||
$hasRow = $con->prepare("SELECT id FROM driverToken WHERE captain_id = ?");
|
||||
$hasRow->execute([$driverID]);
|
||||
if ($hasRow->fetch(PDO::FETCH_ASSOC)) {
|
||||
if (!empty($fcmToken)) {
|
||||
$con->prepare("UPDATE driverToken SET fingerPrint = ?, token = ? WHERE captain_id = ?")
|
||||
->execute([$fpHashed, $encryptionHelper->encryptData($fcmToken), $driverID]);
|
||||
} else {
|
||||
$con->prepare("UPDATE driverToken SET fingerPrint = ? WHERE captain_id = ?")
|
||||
->execute([$fpHashed, $driverID]);
|
||||
}
|
||||
} else {
|
||||
$con->prepare("INSERT INTO driverToken (token, fingerPrint, captain_id, created_at) VALUES (?, ?, ?, NOW())")
|
||||
->execute([
|
||||
!empty($fcmToken) ? $encryptionHelper->encryptData($fcmToken) : '',
|
||||
$fpHashed,
|
||||
$driverID,
|
||||
]);
|
||||
}
|
||||
|
||||
$audDriver = filterRequest("aud") ?: filterRequest("audience") ?: (getenv('allowedDriver2') ?: 'driver-app:ios');
|
||||
$jwtSvc = new JwtService($redis);
|
||||
$accessToken = $jwtSvc->generateAccessToken($driverID, 'driver', $audDriver, $fpRaw);
|
||||
|
||||
jsonSuccess([
|
||||
"driverID" => $driverID,
|
||||
"jwt" => $accessToken,
|
||||
"access_token" => $accessToken,
|
||||
], "Your phone number has been verified.");
|
||||
} else {
|
||||
jsonError("Your phone number could not be verified or the code is expired. Please try again.");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ require_once __DIR__ . '/../connect.php';
|
||||
$platform = filterRequest("platform");
|
||||
$appName = filterRequest("appName");
|
||||
|
||||
// كان platform وappName يُحقنان مباشرة في نص الاستعلام ثم يُمرَّر إلى prepare
|
||||
// بلا معاملات مربوطة — حقن SQL مكتمل الأركان. وfilterRequest لا تنظّف النص
|
||||
// عمداً (انظر core/helpers.php: "بدون htmlspecialchars — نتركه لـ PDO")،
|
||||
// فالحماية الوحيدة المفترضة هنا هي الربط، ولم تكن موجودة.
|
||||
$sql = "SELECT
|
||||
`id`,
|
||||
`platform`,
|
||||
@@ -13,10 +17,10 @@ $sql = "SELECT
|
||||
FROM
|
||||
`packageInfo`
|
||||
WHERE
|
||||
platform='$platform' and appName='$appName';";
|
||||
`platform` = ? AND `appName` = ?";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute();
|
||||
$stmt->execute([$platform, $appName]);
|
||||
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($stmt->rowCount() > 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// File: send_otp.php (بديل عن النسخة المعتمدة على RaseelPlus)
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
/* 1) توليد رمز التحقق (3 خانات) */
|
||||
$otp = (string)random_int(100, 999);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
// File: verify_otp.php (with enhanced logging)
|
||||
// siro_v1/auth/token_passenger
|
||||
require_once __DIR__ . '/../../connect.php';
|
||||
require_once __DIR__ . '/../../../connect.php';
|
||||
|
||||
// --- Start of Script Execution ---
|
||||
error_log("--- [verify_otp.php] Script execution started. ---");
|
||||
|
||||
@@ -19,6 +19,14 @@ try {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// كل مخرجات هذا المحرّك تُكتب في Redis (مقارنة السائقين، ملخّص ذروة
|
||||
// المنافسين، المناطق الساخنة)، فلا معنى لمتابعة التشغيل بدونه.
|
||||
if (!$redis) {
|
||||
error_log('[AI Engine] ABORT: Redis unavailable — no cache target to write to.');
|
||||
die("Redis unavailable — aborting AI engine run.\n");
|
||||
}
|
||||
|
||||
echo "Starting Siro AI Engine v2 (Powered by Pricing Engine)...\n";
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -26,6 +26,14 @@ try {
|
||||
die("Database connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// مفتاح مكافحة التكرار (TTL يوم كامل) يعيش في Redis؛ بدونه يُعاد إرسال
|
||||
// نفس الحملة التسويقية كل ساعة.
|
||||
if (!$redis) {
|
||||
error_log('[AutoMarketingPusher] ABORT: Redis unavailable — anti-spam guard would fail open.');
|
||||
die("Redis unavailable — aborting to avoid duplicate campaign pushes.\n");
|
||||
}
|
||||
|
||||
echo "Starting Auto Marketing Pusher Engine...\n";
|
||||
|
||||
// 1. استخراج الركاب الخاملين في آخر ساعتين (الذين لم يقموا بأي طلب اليوم)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_driver_score.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* يغذّي إشارات ترتيب محرك الإسناد في مفاتيح driver:score:* على Redis.
|
||||
*
|
||||
* بدون هذا الكرون يعمل المحرك بالقرب وملاءمة الوجهة فقط، وتُحسب بقية
|
||||
* المكوّنات محايدة (٠٫٥) — أي أن ٥٠٪ من وزن الترتيب معطّل عملياً:
|
||||
* • معدّل القبول ٢٠٪ ← يُحسب هنا
|
||||
* • التقييم ١٥٪ ← يُحسب هنا
|
||||
* • مدة الخمول ١٥٪ ← يختمها finish_ride_updates.php، لا نلمسها
|
||||
*
|
||||
* ── معدّل القبول ──
|
||||
* لا يوجد في MySQL أي سجل لعرضٍ لم يُقبل: driver_orders يسجّل المقبول
|
||||
* فقط. لذلك يُعدّ العرض في السوكِت (حقل offers) والقبول في acceptRide.php
|
||||
* (حقل accepts)، وهذا الكرون يحوّلهما لنسبة.
|
||||
*
|
||||
* الاضمحلال: نضرب العدّادين بمعامل كل تشغيل، فتصير النسبة نافذة متحرّكة
|
||||
* تعكس السلوك الأخير لا سجلّ العمر كله. سائق أصلح سلوكه يتعافى خلال أيام
|
||||
* بدل أن يظل مسجوناً في ماضيه.
|
||||
*
|
||||
* تنعيم Laplace: سائق عُرض عليه عرضان وقَبِل واحداً لا يستحق ٥٠٪ بثقة
|
||||
* كاملة. نضيف عروضاً وهمية محايدة فتقترب نسبته من المحايد حتى تتراكم
|
||||
* بيانات كافية.
|
||||
*
|
||||
* جدولة مقترحة (crontab): كل ربع ساعة — الإشارات بطيئة التغيّر ولا تستحق أكثر.
|
||||
* الحقل الأول في السطر: الشرطة المائلة ثم 15
|
||||
* ثم: docker compose exec -T php php /var/www/backend/bot/cron_driver_score.php
|
||||
*
|
||||
* ⚠️ يجب أن يعمل داخل حاوية php: Redis لا ينشر منافذ، فتشغيله على المضيف
|
||||
* يعطي $redisLocation = null ويخرج بلا عمل.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
// ── معاملات المعايرة ──
|
||||
const SCORE_DECAY = 0.85; // ما يبقى من العدّادات كل تشغيل
|
||||
const SCORE_PRIOR_OFFERS = 4.0; // عروض وهمية للتنعيم
|
||||
const SCORE_PRIOR_ACCEPTS = 2.0; // منها مقبولة — أي محايد ٠٫٥
|
||||
const SCORE_KEY_TTL = 2592000; // 30 يوماً: سائق اختفى يُنسى وحده
|
||||
|
||||
if (!isset($redisLocation) || !$redisLocation) {
|
||||
fwrite(STDERR, "[driver_score] Redis غير متاح — هل يعمل الكرون داخل حاوية php؟\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, "[driver_score] DB unavailable: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١) التقييمات — استعلام واحد مجمّع لكل السائقين
|
||||
// نافذة ٩٠ يوماً: تقييم قبل سنة لا يصف السائق اليوم.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$ratings = [];
|
||||
try {
|
||||
$stmt = $con->query("
|
||||
SELECT driver_id, AVG(rating) AS avg_rating, COUNT(*) AS cnt
|
||||
FROM ratingDriver
|
||||
WHERE driver_id IS NOT NULL
|
||||
AND rating IS NOT NULL
|
||||
AND created_at >= NOW() - INTERVAL 90 DAY
|
||||
GROUP BY driver_id
|
||||
");
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// تقييم واحد أو اثنان لا يكفيان لحكم — ننعّمه نحو المحايد (٣٫٥/٥)
|
||||
$n = (int) $row['cnt'];
|
||||
$avg = (float) $row['avg_rating'];
|
||||
$ratings[$row['driver_id']] = round((($avg * $n) + (3.5 * 3)) / ($n + 3), 3);
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "[driver_score] تعذّر جلب التقييمات: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٢) المرور على مفاتيح الدرجات الموجودة
|
||||
// scan لا keys: keys تحجب Redis بالكامل على قاعدة كبيرة.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$processed = 0;
|
||||
$iterator = null;
|
||||
|
||||
// بلا هذا الضبط يرجع phpredis false عند دفعة فارغة فتتوقف الحلقة مبكراً
|
||||
$redisLocation->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY);
|
||||
|
||||
while (($keys = $redisLocation->scan($iterator, 'driver:score:*', 200)) !== false) {
|
||||
foreach ($keys as $key) {
|
||||
$driverId = substr($key, strlen('driver:score:'));
|
||||
if ($driverId === '') continue;
|
||||
|
||||
try {
|
||||
$row = $redisLocation->hGetAll($key) ?: [];
|
||||
$offers = (float) ($row['offers'] ?? 0);
|
||||
$accepts = (float) ($row['accepts'] ?? 0);
|
||||
|
||||
// لا يتجاوز المقبول المعروض — يحدث لو أُعيد ضبط أحدهما وحده.
|
||||
$accepts = min($accepts, $offers);
|
||||
|
||||
$rate = ($accepts + SCORE_PRIOR_ACCEPTS) / ($offers + SCORE_PRIOR_OFFERS);
|
||||
|
||||
$update = ['accept_rate' => round($rate, 4)];
|
||||
|
||||
if (isset($ratings[$driverId])) {
|
||||
$update['rating'] = $ratings[$driverId];
|
||||
}
|
||||
|
||||
// الاضمحلال: نافذة متحرّكة بدل سجل العمر كله.
|
||||
$update['offers'] = round($offers * SCORE_DECAY, 3);
|
||||
$update['accepts'] = round($accepts * SCORE_DECAY, 3);
|
||||
|
||||
$redisLocation->hMSet($key, $update);
|
||||
$redisLocation->expire($key, SCORE_KEY_TTL);
|
||||
$processed++;
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "[driver_score] خطأ على $key: " . $e->getMessage() . "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٣) سائقون لهم تقييم ولم يُعرض عليهم شيء بعد
|
||||
// بلا هذه الخطوة يظل التقييم معطّلاً حتى أول عرض — أي أن السائق
|
||||
// الممتاز الجديد لا يستفيد من تقييمه في أول ترتيب له.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$seeded = 0;
|
||||
foreach ($ratings as $driverId => $rating) {
|
||||
$key = "driver:score:$driverId";
|
||||
try {
|
||||
if (!$redisLocation->exists($key)) {
|
||||
$redisLocation->hMSet($key, [
|
||||
'rating' => $rating,
|
||||
'accept_rate' => round(SCORE_PRIOR_ACCEPTS / SCORE_PRIOR_OFFERS, 4),
|
||||
]);
|
||||
$redisLocation->expire($key, SCORE_KEY_TTL);
|
||||
$seeded++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// تجاهُل — المرور القادم يعالجه
|
||||
}
|
||||
}
|
||||
|
||||
echo "[driver_score] حُدِّث $processed سائق، وأُنشئ $seeded من التقييمات."
|
||||
. " تقييمات محمّلة: " . count($ratings) . "\n";
|
||||
@@ -14,6 +14,13 @@ try {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// الهدف الوحيد لهذا السكربت هو الكتابة إلى مفتاح Redis للكاش.
|
||||
if (!$redis) {
|
||||
error_log('[HeatmapCache] ABORT: Redis unavailable — nothing to cache into.');
|
||||
die("Redis unavailable — aborting heatmap cache generation.\n");
|
||||
}
|
||||
|
||||
echo "Starting Heatmap Cache Generation (Redis)...\n";
|
||||
|
||||
// مربعات المدن الكبرى لتمثيل الدول (لتجنب حساب المضلعات المعقدة)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_insurance_premiums.php — قيد أقساط التأمين
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* يقيّد قسط كل وثيقة نشطة بلغت دورتها. لا يخصم من رصيد أحد:
|
||||
*
|
||||
* رصيد السائق يعيش في payment_server لا في هذه القاعدة، والخصم
|
||||
* المباشر من هنا كان سيعني كتابةً عابرة للحدود بلا معاملة موحّدة —
|
||||
* أي خطر رصيد ناقص بلا قيد يقابله، أو قيد بلا خصم. فنقيّد استحقاقاً
|
||||
* هنا، ويقرأه محرك التسوية `cron_obligation_settlement.php`.
|
||||
*
|
||||
* تحديث (٢٠٢٦-٠٨-٠٨): صار هذا الملف **مُصدِر استحقاق** في محرك
|
||||
* الالتزامات العام، فيكتب في `obligation_ledger` لا في
|
||||
* `insurance_premium_ledger`. سبب النقل أن الدفتر القديم لم يكن يقرأه
|
||||
* أحد إطلاقاً — «تقرأه التسوية» كان وعداً بلا منفّذ، وكل قسط قُيّد منذ
|
||||
* الإطلاق بقي معلّقاً بلا تحصيل. جداول التأمين تبقى كما هي شاهداً
|
||||
* تاريخياً، وقد رُحّلت بياناتها في
|
||||
* `migrations/2026_08_08_obligations_engine.sql`.
|
||||
*
|
||||
* الحماية من الاحتساب المزدوج طبقتان:
|
||||
* ١. `last_charged_on` في الالتزام — نفلتر به قبل العمل
|
||||
* ٢. مفتاح فريد (obligation_id, charge_date) في الدفتر — يحسم السباق
|
||||
* لو عملت نسختان من الكرون معاً
|
||||
*
|
||||
* الأولى وحدها لا تكفي: بين القراءة والكتابة نافذة، والمفتاح الفريد
|
||||
* هو ما يجعل تكرار القيد مستحيلاً لا نادراً.
|
||||
*
|
||||
* جدولة: مرة يومياً بعد منتصف الليل.
|
||||
* 15 0 * * * docker compose exec -T php php /var/www/backend/bot/cron_insurance_premiums.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, '[insurance] DB unavailable: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
// الشهري يُقيَّد على أول الشهر لا على يوم الاشتراك: دفتر تتوزّع فيه
|
||||
// تواريخ القيد على ثلاثين يوماً يستحيل تسويته مع الشريك.
|
||||
$monthStart = date('Y-m-01');
|
||||
|
||||
$charged = 0;
|
||||
$skipped = 0;
|
||||
|
||||
try {
|
||||
// المصدر الآن الالتزامات لا الوثائق. القيد على `kind = 'recurring'`
|
||||
// لا على المنتج التأميني تحديداً: أي منتج دوري يُضاف لاحقاً يُقيَّد
|
||||
// بالمنطق نفسه بلا سطر جديد هنا.
|
||||
$st = $con->query("
|
||||
SELECT o.id, o.driver_id, o.last_charged_on, o.cycle_amount,
|
||||
pr.code AS product_code, pr.currency, pr.billing_cycle
|
||||
FROM driver_obligations o
|
||||
JOIN obligation_products pr ON pr.id = o.product_id
|
||||
WHERE o.status = 'active'
|
||||
AND pr.kind = 'recurring'
|
||||
AND pr.billing_cycle <> 'none'
|
||||
ORDER BY o.id ASC
|
||||
");
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $policy) {
|
||||
$chargeDate = $policy['billing_cycle'] === 'monthly' ? $monthStart : $today;
|
||||
|
||||
// الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل التزام كل يوم.
|
||||
if ($policy['last_charged_on'] === $chargeDate) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
$con->prepare("
|
||||
INSERT INTO obligation_ledger
|
||||
(obligation_id, driver_id, product_code, charge_date,
|
||||
amount, amount_collected, amount_remaining, currency, status)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?, 'pending')
|
||||
")->execute([
|
||||
$policy['id'], $policy['driver_id'], $policy['product_code'], $chargeDate,
|
||||
$policy['cycle_amount'], $policy['cycle_amount'], $policy['currency'],
|
||||
]);
|
||||
|
||||
$con->prepare("
|
||||
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
|
||||
")->execute([$chargeDate, $policy['id']]);
|
||||
|
||||
$con->commit();
|
||||
$charged++;
|
||||
} catch (PDOException $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
|
||||
// 23000 = خرق المفتاح الفريد: نسخة أخرى سبقتنا للقيد نفسه.
|
||||
// هذه ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه.
|
||||
if ($e->getCode() === '23000') {
|
||||
$con->prepare("
|
||||
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
|
||||
")->execute([$chargeDate, $policy['id']]);
|
||||
$skipped++;
|
||||
} else {
|
||||
error_log("[insurance] تعذّر قيد استحقاق الالتزام #{$policy['id']}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[insurance] فشل المرور: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "[insurance] قُيِّد {$charged} قسطاً، تُخطّي {$skipped}\n";
|
||||
@@ -18,6 +18,14 @@ try {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() تُعيد المتغير العام $redis فقط، وbootstrap.php يتركه null
|
||||
// عند فشل الاتصال — فهي لا ترمي استثناءً والـ try/catch أعلاه لا يلتقط شيئاً.
|
||||
// بدون هذا الفحص كان السكربت ينهار فوراً بـ "call to a member function get() on null".
|
||||
if (!$redis) {
|
||||
error_log('[cron_kazan_adjuster] ABORT: Redis unavailable — Kazan discounts left untouched.');
|
||||
die("Redis unavailable — aborting without touching Kazan discounts.\n");
|
||||
}
|
||||
|
||||
$countries = ['SY', 'JO', 'EG', 'IQ'];
|
||||
|
||||
foreach ($countries as $country) {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_leak_detection.php
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* كشف التسرب خارج المنصة — الرحلة تحصل فعلاً والعمولة لا تصل.
|
||||
*
|
||||
* ── لماذا خادمياً ──
|
||||
* جدول driver_ride_scam يملؤه تطبيق السائق عن نفسه، فنسخة معدّلة لا
|
||||
* ترسل شيئاً. هذا الكرون يستنتج النمط من بيانات لا يملك السائق تزويرها:
|
||||
* سجل القبول في driver_orders، وحالة الرحلة في ride.
|
||||
*
|
||||
* ── النمط ──
|
||||
* قَبِل السائق → أُلغيت الرحلة → ولم تبدأ قط
|
||||
* مرة واحدة قد تكون صدفة. تكرار نفس زوج (سائق، راكب) ليس صدفة.
|
||||
*
|
||||
* ── العقوبة ──
|
||||
* لا حظر. تُكتب درجة الاشتباه في driver:score:$id (حقل leak_penalty)
|
||||
* فتخفض ترتيب السائق في محرك الإسناد — يخسر السبق والرحلات الأقرب
|
||||
* تدريجياً. موجع بلا حرمان، وقابل للتراجع فوراً إن تبيّن الخطأ.
|
||||
* الحالات عالية الثقة تُترك في driver_leak_events لمراجعة بشرية.
|
||||
*
|
||||
* جدولة مقترحة (crontab): كل ساعة.
|
||||
* docker compose exec -T php php /var/www/backend/bot/cron_leak_detection.php
|
||||
*
|
||||
* ⚠️ يجب أن يعمل داخل حاوية php — Redis لا ينشر منافذ.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
// ── معاملات المعايرة ──
|
||||
const LEAK_WINDOW_DAYS = 30; // نافذة احتساب التكرار
|
||||
const LEAK_SCAN_HOURS = 48; // عمق الفحص في كل مرور (بهامش للتأخر)
|
||||
const LEAK_PENALTY_MAX = 1.0; // سقف العقوبة
|
||||
const LEAK_REVIEW_THRESHOLD = 0.70; // فوقها تستحق مراجعة بشرية
|
||||
|
||||
// الحالات التي تعني "أُلغيت بعد القبول"
|
||||
const LEAK_CANCEL_STATUSES = ['CancelFromDriverAfterApply', 'cancelled_by_passenger'];
|
||||
|
||||
// الحالات التي تعني أن الرحلة بدأت أو اكتملت — وجودها ينفي الاشتباه.
|
||||
// 'arrived' ليست منها عمداً: الوصول ثم الإلغاء هو النمط المشتبه نفسه،
|
||||
// لا نفيه. (عملياً الإلغاء يكتب فوق الحالة، فالفلتر يعمل على النهائية.)
|
||||
const LEAK_STARTED_STATUSES = ['started', 'Begin', 'Finished', 'finished'];
|
||||
|
||||
if (!isset($redisLocation) || !$redisLocation) {
|
||||
fwrite(STDERR, "[leak] Redis غير متاح — هل يعمل الكرون داخل حاوية php؟\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, "[leak] DB unavailable: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١) الرحلات المشتبهة في النافذة الأخيرة
|
||||
//
|
||||
// ملاحظة: cancel_ride_by_driver.php يصفّر ride.driver_id عند الإلغاء،
|
||||
// فالسائق لا يُعرف من جدول ride. driver_orders هو من يحتفظ بالربط.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$cancelPlaceholders = implode(',', array_fill(0, count(LEAK_CANCEL_STATUSES), '?'));
|
||||
$startedPlaceholders = implode(',', array_fill(0, count(LEAK_STARTED_STATUSES), '?'));
|
||||
|
||||
$sql = "
|
||||
SELECT dord.driver_id, dord.order_id AS ride_id, dord.status AS cancel_status,
|
||||
r.passenger_id
|
||||
FROM driver_orders dord
|
||||
JOIN ride r ON r.id = dord.order_id
|
||||
WHERE dord.status IN ($cancelPlaceholders)
|
||||
AND dord.created_at >= NOW() - INTERVAL " . LEAK_SCAN_HOURS . " HOUR
|
||||
AND r.status NOT IN ($startedPlaceholders)
|
||||
AND dord.driver_id <> '0'
|
||||
AND r.passenger_id <> ''
|
||||
";
|
||||
|
||||
try {
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute(array_merge(LEAK_CANCEL_STATUSES, LEAK_STARTED_STATUSES));
|
||||
$suspects = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "[leak] فشل الفحص: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٢) تقييم كل حالة وتسجيلها
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$pairStmt = $con->prepare("
|
||||
SELECT COUNT(*) FROM driver_leak_events
|
||||
WHERE driver_id = ? AND passenger_id = ?
|
||||
AND created_at >= NOW() - INTERVAL " . LEAK_WINDOW_DAYS . " DAY
|
||||
");
|
||||
|
||||
$appStmt = $con->prepare("
|
||||
SELECT COUNT(*) FROM driver_ride_scam
|
||||
WHERE driverID = ? AND rideID = ?
|
||||
");
|
||||
|
||||
$insertStmt = $con->prepare("
|
||||
INSERT IGNORE INTO driver_leak_events
|
||||
(driver_id, passenger_id, ride_id, cancel_status, pair_repeat, app_reported, confidence)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
|
||||
$inserted = 0;
|
||||
$flagged = 0;
|
||||
|
||||
foreach ($suspects as $s) {
|
||||
try {
|
||||
$pairStmt->execute([$s['driver_id'], $s['passenger_id']]);
|
||||
$pairRepeat = ((int) $pairStmt->fetchColumn()) + 1;
|
||||
|
||||
$appStmt->execute([$s['driver_id'], $s['ride_id']]);
|
||||
$appReported = ((int) $appStmt->fetchColumn()) > 0 ? 1 : 0;
|
||||
|
||||
// ── الثقة ──
|
||||
// حالة منفردة ضعيفة عمداً: الإلغاء بعد القبول له أسباب مشروعة
|
||||
// كثيرة (عطل، خطأ في العنوان، راكب غيّر رأيه). التكرار مع نفس
|
||||
// الراكب هو ما يحوّلها من حادثة إلى نمط.
|
||||
$confidence = 0.25;
|
||||
if ($pairRepeat >= 2) $confidence = 0.60;
|
||||
if ($pairRepeat >= 3) $confidence = 0.85;
|
||||
|
||||
// بلاغ التطبيق (اتصل ثم أُلغيت) يرفع الثقة ولا يصنعها وحده.
|
||||
if ($appReported) $confidence = min(1.0, $confidence + 0.15);
|
||||
|
||||
$insertStmt->execute([
|
||||
$s['driver_id'], $s['passenger_id'], $s['ride_id'],
|
||||
$s['cancel_status'], $pairRepeat, $appReported, round($confidence, 3),
|
||||
]);
|
||||
|
||||
if ($insertStmt->rowCount() > 0) {
|
||||
$inserted++;
|
||||
if ($confidence >= LEAK_REVIEW_THRESHOLD) $flagged++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "[leak] خطأ على الرحلة {$s['ride_id']}: " . $e->getMessage() . "\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٣) تحويل السجل إلى عقوبة في محرك الإسناد
|
||||
//
|
||||
// العقوبة تُحسب من مجموع ثقة أحداث السائق في النافذة، لا من عددها —
|
||||
// فحالتان ضعيفتان لا تساويان حالة واحدة مؤكدة.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$penalized = 0;
|
||||
try {
|
||||
$scoreStmt = $con->prepare("
|
||||
SELECT driver_id, SUM(confidence) AS total
|
||||
FROM driver_leak_events
|
||||
WHERE created_at >= NOW() - INTERVAL " . LEAK_WINDOW_DAYS . " DAY
|
||||
GROUP BY driver_id
|
||||
");
|
||||
$scoreStmt->execute();
|
||||
|
||||
foreach ($scoreStmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// يُشبَع عند مجموع 3 — أي ثلاث حالات مؤكدة تقريباً = العقوبة كاملة.
|
||||
$penalty = min(LEAK_PENALTY_MAX, ((float) $row['total']) / 3.0);
|
||||
$redisLocation->hSet("driver:score:{$row['driver_id']}", 'leak_penalty', round($penalty, 3));
|
||||
$penalized++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "[leak] تعذّر تطبيق العقوبات: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// تصفير عقوبة من خرج من النافذة: بلا هذا تبقى العقوبة أبدية في Redis
|
||||
// حتى لو نظُف سجل السائق تماماً — أي عقوبة بلا انتهاء.
|
||||
try {
|
||||
$iterator = null;
|
||||
$redisLocation->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY);
|
||||
$activeStmt = $con->prepare("
|
||||
SELECT DISTINCT driver_id FROM driver_leak_events
|
||||
WHERE created_at >= NOW() - INTERVAL " . LEAK_WINDOW_DAYS . " DAY
|
||||
");
|
||||
$activeStmt->execute();
|
||||
$active = array_flip($activeStmt->fetchAll(PDO::FETCH_COLUMN));
|
||||
|
||||
while (($keys = $redisLocation->scan($iterator, 'driver:score:*', 200)) !== false) {
|
||||
foreach ($keys as $key) {
|
||||
$driverId = substr($key, strlen('driver:score:'));
|
||||
if ($driverId === '' || isset($active[$driverId])) continue;
|
||||
if ($redisLocation->hExists($key, 'leak_penalty')) {
|
||||
$redisLocation->hDel($key, 'leak_penalty');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, "[leak] تعذّر تنظيف العقوبات المنتهية: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[leak] فُحص " . count($suspects) . " حالة، سُجّل $inserted جديدة،"
|
||||
. " منها $flagged تستحق مراجعة بشرية. عوقب $penalized سائق.\n";
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_obligation_settlement.php — محرك تسوية الالتزامات
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* الجهة **الوحيدة** التي تحوّل قيداً في `obligation_ledger` إلى خصم فعلي
|
||||
* من محفظة السائق. مُصدِرو الاستحقاق (التأمين، الوقود، الصيانة) يقيّدون
|
||||
* فقط ولا يلمسون مالاً — وهذا ما يجعل إضافة منتج جديد لا تمسّ أخطر
|
||||
* منطق في المنصة.
|
||||
*
|
||||
* هذا الملف يغلق ثغرة إنتاج قائمة: `cron_insurance_premiums.php` يقيّد
|
||||
* الأقساط منذ إطلاقه وتعليقه يقول «تقرأه التسوية» — ولم تكن التسوية
|
||||
* موجودة. كل قسط قُيّد حتى اليوم بقي `pending` بلا تحصيل.
|
||||
*
|
||||
* لماذا كرون منفصل لا خصم عند إنهاء الرحلة؟
|
||||
* لأن `finish_ride_updates.php` مسار حرج والسائق ينتظر رده. نداء شبكي
|
||||
* إلى سيرفر المحفظة داخله يعني تعليق شاشة السائق على بطء طرف ثالث،
|
||||
* وفشلاً في التسوية يظهر للسائق كفشل في إنهاء رحلته.
|
||||
*
|
||||
* ضمانات عدم التكرار طبقتان:
|
||||
* ١. `last_attempt_on` في القيد — محاولة واحدة لكل قيد في اليوم
|
||||
* ٢. `settlement_ref` فريد في سيرفر المحفظة — يحسم السباق ويجعل
|
||||
* إعادة النداء بعد انقطاع شبكي آمنة بالتعريف
|
||||
*
|
||||
* الأولى وحدها لا تكفي: نداء قد ينجح في المحفظة ثم ينقطع قبل أن يصل
|
||||
* الرد، فيبقى القيد بلا تحديث. الطبقة الثانية تجعل إعادة المحاولة تُرجع
|
||||
* نتيجة الخصم الأول بدل خصم ثانٍ.
|
||||
*
|
||||
* جدولة: مرة يومياً في آخر اليوم لا بعد قيد الاستحقاقات مباشرة —
|
||||
* السقف نسبة من أرباح اليوم، وقراءتها بعد منتصف الليل تعني سقفاً صفرياً
|
||||
* كل ليلة وتحصيلاً لا يقع أبداً.
|
||||
* 30 23 * * * docker compose exec -T php php /var/www/backend/bot/cron_obligation_settlement.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../obligations/functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, '[obligation] DB unavailable: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$sharedKey = getenv('S2S_SHARED_KEY') ?: '';
|
||||
if ($sharedKey === '') {
|
||||
// بلا مفتاح لن ينجح أي نداء. الخروج المبكر أوضح من مئة محاولة
|
||||
// تعود 401 وتزيد `attempts` بلا سبب حقيقي.
|
||||
fwrite(STDERR, "[obligation] S2S_SHARED_KEY غير معرّف — لا تسوية\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* ينادي نقطة الخصم ويُرجع ما خُصم فعلاً، أو null عند فشل لا يمكن تفسيره.
|
||||
*
|
||||
* التمييز بين «خُصم صفر» و«فشل» جوهري: الأول حالة طبيعية (لا أرباح
|
||||
* اليوم) تُسجَّل محاولةً، والثاني لا يجوز أن يُحسب محاولة — وإلا ضاع
|
||||
* يوم تحصيل بسبب عطل شبكي عابر.
|
||||
*/
|
||||
function obligationDeduct(string $ref, string $driverId, string $productCode,
|
||||
float $amount, float $capPercent): ?float
|
||||
{
|
||||
$payload = obligationWalletCall(
|
||||
'/v2/main/ride/driverWallet/deduct_s2s_obligation.php',
|
||||
[
|
||||
'settlement_ref' => $ref,
|
||||
'driverID' => $driverId,
|
||||
'product_code' => $productCode,
|
||||
'amount' => number_format($amount, 3, '.', ''),
|
||||
'cap_percent' => number_format($capPercent, 2, '.', ''),
|
||||
]
|
||||
);
|
||||
|
||||
if ($payload === null || !isset($payload['deducted'])) {
|
||||
error_log("[obligation] تعذّر الحصول على نتيجة خصم ($ref)");
|
||||
return null;
|
||||
}
|
||||
|
||||
return round((float) $payload['deducted'], 3);
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$settled = 0;
|
||||
$partial = 0;
|
||||
$idle = 0;
|
||||
$failed = 0;
|
||||
$total = 0.0;
|
||||
|
||||
try {
|
||||
// الترتيب مقصود: السائق أولاً ليكون سقفه اليومي مُستهلَكاً بترتيب
|
||||
// معلوم، ثم أولوية المنتج (التأمين قبل الوقود قبل الصيانة)، ثم
|
||||
// الأقدم فالأحدث — الدَّين الأقدم أولى بالسداد.
|
||||
$st = $con->query("
|
||||
SELECT l.id, l.obligation_id, l.driver_id, l.product_code,
|
||||
l.amount_remaining, l.amount_collected, l.amount, l.attempts,
|
||||
p.daily_cap_percent, p.kind
|
||||
FROM obligation_ledger l
|
||||
JOIN driver_obligations o ON o.id = l.obligation_id
|
||||
JOIN obligation_products p ON p.id = o.product_id
|
||||
WHERE l.status IN ('pending', 'partial')
|
||||
AND l.amount_remaining > 0
|
||||
AND (l.last_attempt_on IS NULL OR l.last_attempt_on <> CURDATE())
|
||||
ORDER BY l.driver_id ASC, p.priority ASC, l.charge_date ASC
|
||||
");
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$ledgerId = (int) $row['id'];
|
||||
$remaining = round((float) $row['amount_remaining'], 3);
|
||||
|
||||
// المرجع مبنيّ على عدّاد المحاولات لا على تاريخ اليوم. الفرق ليس
|
||||
// تجميلياً: `attempts` لا يزيد إلا بعد تحديث الدفتر بنجاح، فنداء
|
||||
// نجح في المحفظة وضاع ردّه يُعاد لاحقاً — ولو بعد أيام — بالمرجع
|
||||
// نفسه، فتُرجع المحفظة خصمه الأول بدل تنفيذ خصم ثانٍ. مرجعٌ
|
||||
// مبنيّ على التاريخ كان سيخصم المبلغ مرتين في هذه الحالة بالضبط.
|
||||
$ref = "obl_{$ledgerId}_{$row['attempts']}";
|
||||
|
||||
$deducted = obligationDeduct(
|
||||
$ref, $row['driver_id'], $row['product_code'],
|
||||
$remaining, (float) $row['daily_cap_percent']
|
||||
);
|
||||
|
||||
if ($deducted === null) {
|
||||
// لا نلمس القيد إطلاقاً — ولا حتى `attempts`. قد يكون الخصم
|
||||
// تمّ في المحفظة وضاع الرد، وإبقاء العدّاد كما هو يضمن أن
|
||||
// المحاولة القادمة تحمل المرجع نفسه فتستردّ نتيجة الأولى.
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$collected = round((float) $row['amount_collected'] + $deducted, 3);
|
||||
$newRemaining = round((float) $row['amount'] - $collected, 3);
|
||||
if ($newRemaining < 0) {
|
||||
$newRemaining = 0.0;
|
||||
}
|
||||
|
||||
if ($newRemaining <= 0) {
|
||||
$status = 'settled';
|
||||
} elseif ($collected > 0) {
|
||||
$status = 'partial';
|
||||
} else {
|
||||
$status = 'pending';
|
||||
}
|
||||
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
$con->prepare("
|
||||
UPDATE obligation_ledger
|
||||
SET amount_collected = ?, amount_remaining = ?, status = ?,
|
||||
attempts = attempts + 1, last_attempt_on = ?,
|
||||
settled_at = CASE WHEN ? = 'settled' THEN NOW() ELSE settled_at END
|
||||
WHERE id = ?
|
||||
")->execute([$collected, $newRemaining, $status, $today, $status, $ledgerId]);
|
||||
|
||||
// المقسّط وحده يراكم أصلاً مسدَّداً. الدوري بلا نهاية فلا
|
||||
// معنى لأصلٍ ينفد عنده.
|
||||
if ($deducted > 0 && $row['kind'] === 'installment') {
|
||||
$con->prepare("
|
||||
UPDATE driver_obligations
|
||||
SET principal_paid = principal_paid + ?
|
||||
WHERE id = ?
|
||||
")->execute([$deducted, (int) $row['obligation_id']]);
|
||||
|
||||
// سداد الأصل كاملاً يُنهي الالتزام. بدون هذا يبقى قرض
|
||||
// مسدَّد «نشطاً» فيظهر للسائق ويزاحم غيره على السقف.
|
||||
$con->prepare("
|
||||
UPDATE driver_obligations
|
||||
SET status = 'completed', ended_at = CURDATE()
|
||||
WHERE id = ? AND status = 'active'
|
||||
AND principal_amount > 0
|
||||
AND principal_paid >= principal_amount
|
||||
")->execute([(int) $row['obligation_id']]);
|
||||
}
|
||||
|
||||
$con->commit();
|
||||
} catch (PDOException $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
// الخصم تمّ فعلاً والقيد لم يُحدَّث — أخطر حالة هنا. لا إعادة
|
||||
// محاولة تلقائية: المرجع نفسه سيُرجع الخصم الأول لا خصماً
|
||||
// جديداً، لكن التباين بين الدفترين يحتاج عيناً بشرية.
|
||||
error_log("[obligation] خُصم {$deducted} للقيد #{$ledgerId} ولم يُحدَّث الدفتر: " . $e->getMessage());
|
||||
$failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$total += $deducted;
|
||||
if ($status === 'settled') $settled++;
|
||||
elseif ($deducted > 0) $partial++;
|
||||
else $idle++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[obligation] فشل المرور: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
printf(
|
||||
"[obligation] سُوِّي %d، جزئي %d، بلا خصم %d، فشل %d — إجمالي %.3f\n",
|
||||
$settled, $partial, $idle, $failed, $total
|
||||
);
|
||||
@@ -28,6 +28,14 @@ try {
|
||||
die("[ReEngagement] Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// مفتاح مكافحة التكرار يعيش في Redis؛ بدونه يفشل الفحص "مفتوحاً"
|
||||
// فيتلقّى كل راكب نفس رسالة الاستعادة في كل تشغيل.
|
||||
if (!$redis) {
|
||||
error_log('[ReEngagement] ABORT: Redis unavailable — anti-spam guard would fail open.');
|
||||
die("[ReEngagement] Redis unavailable — aborting to avoid duplicate pushes.\n");
|
||||
}
|
||||
|
||||
echo "[ReEngagement] Starting Passenger Re-engagement Engine...\n";
|
||||
|
||||
$REDIS_TTL = 72 * 3600; // anti-spam: 72 ساعة
|
||||
|
||||
@@ -34,6 +34,14 @@ try {
|
||||
die("[PredictiveDemand] Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// الإيقاف هنا مقصود: مفتاح مكافحة التكرار يعيش في Redis، وبدونه سيُعاد
|
||||
// إرسال نفس الإشعارات للركاب في كل تشغيل.
|
||||
if (!$redis) {
|
||||
error_log('[PredictiveDemand] ABORT: Redis unavailable — anti-spam guard would fail open.');
|
||||
die("[PredictiveDemand] Redis unavailable — aborting to avoid duplicate pushes.\n");
|
||||
}
|
||||
|
||||
echo "[PredictiveDemand] Starting...\n";
|
||||
|
||||
$GRID_SIZE = 0.01; // حجم الخلية (~1.1km)
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
<?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");
|
||||
}
|
||||
|
||||
// الأقواس ضرورية: الفاصلة العربية `،` بايتاتها ≥ 0x80، وPHP يعدّها جزءاً
|
||||
// صالحاً من اسم المتغيّر — فبدونها يُقرأ "$dispatched،" متغيّراً آخر فارغاً.
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@ try {
|
||||
die("Connection failed: " . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// getRedisConnection() لا ترمي استثناءً — تُعيد null عند تعذّر الاتصال.
|
||||
// المُعاملات الموسمية تُنشر عبر Redis وحده ليقرأها محرّك التسعير.
|
||||
if (!$redis) {
|
||||
error_log('[SeasonalPricing] ABORT: Redis unavailable — seasonal multipliers not published.');
|
||||
die("Redis unavailable — aborting seasonal pricing run.\n");
|
||||
}
|
||||
|
||||
$countries = ['SY' => 'Asia/Damascus', 'JO' => 'Asia/Amman', 'EG' => 'Africa/Cairo', 'IQ' => 'Asia/Baghdad'];
|
||||
|
||||
// Simulated Calendar of Events
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* cron_sms_requests.php — شبكة الأمان لمسار الطلب بالرسائل
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* المعالجة الأصلية تتم فورياً داخل webhook_sms/webhook.php، لأن الراكب
|
||||
* واقف ينتظر رداً ودقيقة تأخير تعني أنه ظنّ الرقم معطّلاً. هذا الكرون
|
||||
* لا يعالج المسار السعيد إذن — وظيفته ما يسقط منه:
|
||||
*
|
||||
* ١. طلبات بقيت 'pending' (فشل شبكي عابر وقت الإنشاء)
|
||||
* ٢. طلبات عَلِقت في 'dispatching' (مات التنفيذ بين القفل والنتيجة)
|
||||
* ٣. رحلات لم يقبلها أحد — نخبر الراكب بدل تركه ينتظر بلا خبر
|
||||
*
|
||||
* الثالثة هي الأهم: راكب التطبيق يرى الخريطة فيعرف أن البحث جارٍ.
|
||||
* راكب الرسائل لا يرى شيئاً — صمتنا عنده يساوي عطلاً.
|
||||
*
|
||||
* جدولة: كل دقيقة.
|
||||
* *\/1 * * * * docker compose exec -T php php /var/www/backend/bot/cron_sms_requests.php
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../sms/ride_request.php';
|
||||
|
||||
// طلب عَلِق في 'dispatching' أطول من هذا يُعتبر ميتاً ويُعاد للطابور.
|
||||
const SMS_STUCK_MINUTES = 3;
|
||||
|
||||
// بعد هذه المدة بلا سائق نخبر الراكب. أقصر من مهلة التطبيق عمداً:
|
||||
// من ينتظر بلا شاشة يفقد صبره أسرع.
|
||||
const SMS_NO_DRIVER_MINUTES = 6;
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, '[sms-cron] DB unavailable: ' . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ١) تحرير العالق
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$freed = 0;
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
UPDATE sms_ride_requests
|
||||
SET status = 'pending'
|
||||
WHERE status = 'dispatching'
|
||||
AND updated_at < NOW() - INTERVAL " . SMS_STUCK_MINUTES . " MINUTE
|
||||
");
|
||||
$st->execute();
|
||||
$freed = $st->rowCount();
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[sms-cron] تعذّر تحرير العالق: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٢) إعادة محاولة المعلّق
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$retried = 0;
|
||||
$created = 0;
|
||||
try {
|
||||
$st = $con->query("
|
||||
SELECT id, sender_norm, passenger_id, start_location, end_location,
|
||||
start_name, end_name, distance, duration, attempts
|
||||
FROM sms_ride_requests
|
||||
WHERE status = 'pending' AND passenger_id IS NOT NULL
|
||||
ORDER BY id ASC
|
||||
LIMIT 50
|
||||
");
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $req) {
|
||||
$retried++;
|
||||
if (smsProcessRequest($con, $req)) $created++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[sms-cron] فشل إعادة المحاولة: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ٣) إبلاغ من لم يجد سائقاً
|
||||
//
|
||||
// نعتمد على حالة الرحلة نفسها لا على مؤقّت منفصل: ما دامت 'waiting'
|
||||
// فلا سائق قَبِل. والوسم في fail_reason يمنع تكرار الإبلاغ كل دقيقة.
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
$notified = 0;
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
SELECT s.id, s.sender_norm, s.ride_id
|
||||
FROM sms_ride_requests s
|
||||
JOIN ride r ON r.id = s.ride_id
|
||||
WHERE s.status = 'dispatched'
|
||||
AND (s.fail_reason IS NULL OR s.fail_reason <> 'no_driver_notified')
|
||||
AND r.status IN ('waiting', 'wait')
|
||||
AND s.updated_at < NOW() - INTERVAL " . SMS_NO_DRIVER_MINUTES . " MINUTE
|
||||
LIMIT 50
|
||||
");
|
||||
$st->execute();
|
||||
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// الوسم أولاً ثم الإرسال: لو انقطع التنفيذ بينهما، أسوأ نتيجة
|
||||
// رسالة لم تصل — لا رسالة تصل كل دقيقة.
|
||||
$con->prepare("UPDATE sms_ride_requests SET fail_reason='no_driver_notified' WHERE id=?")
|
||||
->execute([$row['id']]);
|
||||
|
||||
smsEnqueue($con, $row['sender_norm'],
|
||||
'سيرو: ما زلنا نبحث عن سائق قريب منك. سنرسل لك رسالة فور قبول أحدهم،'
|
||||
. ' أو أعد إرسال طلبك بعد قليل.',
|
||||
'no_driver_yet', (string) $row['ride_id']);
|
||||
$notified++;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, '[sms-cron] تعذّر إبلاغ من بلا سائق: ' . $e->getMessage() . "\n");
|
||||
}
|
||||
|
||||
echo "[sms-cron] حُرِّر {$freed}، أُعيدت محاولة {$retried}، أُنشئ {$created}،"
|
||||
. " أُبلغ {$notified}\n";
|
||||
+42
-13
@@ -23,7 +23,15 @@ if (!$SECRET_KEY) {
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Server configuration error: BOT_SECRET_KEY not set in environment']);
|
||||
exit;
|
||||
}
|
||||
$ALLOWED_DEVICES = ['SHAM_CASH_BOT_01', 'PRICE_SCRAPER_BOT_01', '77a9528112ef7c2a'];
|
||||
// أجهزة البوت المسموح بها — تُضبط عبر BOT_ALLOWED_DEVICES (مفصولة بفواصل)
|
||||
// حتى لا يتطلب ضم جهاز جديد تعديل الكود ونشره.
|
||||
$ALLOWED_DEVICES = array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', (string)(getenv('BOT_ALLOWED_DEVICES') ?: ''))
|
||||
)));
|
||||
if (!$ALLOWED_DEVICES) {
|
||||
$ALLOWED_DEVICES = ['SHAM_CASH_BOT_01', 'PRICE_SCRAPER_BOT_01', '77a9528112ef7c2a'];
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
@@ -43,9 +51,12 @@ if ($method === 'GET') {
|
||||
// ---------------------------------------------------------
|
||||
// GET: Fetch Pending Task for the Bot
|
||||
// ---------------------------------------------------------
|
||||
$device_id = filterRequest('device_id');
|
||||
$ts = filterRequest('ts');
|
||||
$sig = filterRequest('sig');
|
||||
// ملاحظة: filterRequest() تقرأ من POST أو JSON body فقط ولا تلمس $_GET
|
||||
// (backend/core/helpers.php) — والبوت يرسل بارامترات التوقيع في الـ query
|
||||
// string، لذا تُقرأ هنا من $_GET مباشرة.
|
||||
$device_id = isset($_GET['device_id']) ? trim((string)$_GET['device_id']) : null;
|
||||
$ts = isset($_GET['ts']) ? (int)$_GET['ts'] : null;
|
||||
$sig = isset($_GET['sig']) ? trim((string)$_GET['sig']) : null;
|
||||
|
||||
if (!$device_id || !$ts || !$sig) {
|
||||
jsonError("Missing security parameters");
|
||||
@@ -64,6 +75,14 @@ if ($method === 'GET') {
|
||||
|
||||
// Check Redis Queue for tasks
|
||||
// Queue Name: queue:bot:tasks (Using Main Redis)
|
||||
// bootstrap.php يترك $redis = null إذا فشل الاتصال — بدون هذا الفحص
|
||||
// كان الطلب ينهار بخطأ 500 غامض بدل رسالة واضحة.
|
||||
if (!$redis) {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Task queue unavailable']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$taskJson = $redis->rpop('queue:bot:tasks');
|
||||
|
||||
if ($taskJson) {
|
||||
@@ -144,18 +163,28 @@ if ($method === 'GET') {
|
||||
}
|
||||
// ------------------------------------------------------
|
||||
|
||||
// العملة تتبع الدولة بدل تثبيتها على JOD — البوت يعمل على أكثر من سوق
|
||||
$currencyByCountry = ['JO' => 'JOD', 'SY' => 'SYP', 'EG' => 'EGP'];
|
||||
$currency = $currencyByCountry[strtoupper($country_code)] ?? 'JOD';
|
||||
|
||||
// 1. Save to MySQL
|
||||
$stmt = $con->prepare("
|
||||
INSERT INTO scraped_competitor_prices
|
||||
(task_id, app_name, competitor_name, start_location, end_location, start_lat, start_lng, end_lat, end_lng, price_amount, price_per_km, distance_km, duration_min, currency, country_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$start_loc = "Lat: $start_lat, Lng: $start_lng";
|
||||
$end_loc = "Lat: $end_lat, Lng: $end_lng";
|
||||
$stmt->execute([$task_id, $app_name, $app_name, $start_loc, $end_loc, (string)$start_lat, (string)$start_lng, (string)$end_lat, (string)$end_lng, $price, $pricePerKm, $distance_km, $duration_min, 'JOD', $country_code]);
|
||||
try {
|
||||
$stmt = $con->prepare("
|
||||
INSERT INTO scraped_competitor_prices
|
||||
(task_id, app_name, competitor_name, start_location, end_location, start_lat, start_lng, end_lat, end_lng, price_amount, price_per_km, distance_km, duration_min, currency, country_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$start_loc = "Lat: $start_lat, Lng: $start_lng";
|
||||
$end_loc = "Lat: $end_lat, Lng: $end_lng";
|
||||
$stmt->execute([$task_id, $app_name, $app_name, $start_loc, $end_loc, (string)$start_lat, (string)$start_lng, (string)$end_lat, (string)$end_lng, $price, $pricePerKm, $distance_km, $duration_min, $currency, $country_code]);
|
||||
} catch (PDOException $e) {
|
||||
// لا نُسقط الطلب: نسجّل الخطأ ونكمل إلى Redis حتى لا تضيع العيّنة
|
||||
// من محرّك التسعير بسبب عطل في الكتابة إلى MySQL.
|
||||
error_log("[Bot Worker] MySQL insert failed for task $task_id: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// 2. Save to Redis (Calculate Price Per KM)
|
||||
if ($distance_km > 0 && $price > 0) {
|
||||
if ($redis && $distance_km > 0 && $price > 0) {
|
||||
// Store in Redis (Main) to be used by Pricing Engine
|
||||
// Store recent 50 prices for the app
|
||||
$redis->lpush("competitor:price_history:$app_name", $pricePerKm);
|
||||
|
||||
@@ -33,6 +33,75 @@ $decoded = $jwtService->authenticate();
|
||||
$user_id = $decoded->user_id ?? null;
|
||||
$role = $decoded->role ?? 'passenger';
|
||||
|
||||
// ============================================================
|
||||
// 2. بوّابة صلاحيات افتراضية لمسار /Admin
|
||||
// ============================================================
|
||||
// النموذج السابق كان «آمن إن تذكّرت»: كل نقطة تحت Admin/ تفحص الدور بنفسها،
|
||||
// ومن يُغفل الفحص يصبح مكشوفاً. وقد وُجد فعلاً أن getPassengerDetails.php
|
||||
// و getPassengerDetailsByPassengerID.php و getPassengerbyEmail.php كانت بلا
|
||||
// أي فحص، فكان أي حامل JWT صالح — بما فيه راكب عادي — يقرأ بيانات ركّاب
|
||||
// مفكوكة التشفير.
|
||||
//
|
||||
// هنا نقلب النموذج إلى «آمن افتراضاً»: كل ملف تحت Admin/ يمرّ عبر connect.php
|
||||
// يتطلب دور admin أو super_admin، ما لم يكن في قائمة استثناء صريحة.
|
||||
//
|
||||
// نقاط تسجيل الدخول (Admin/auth/*, jwtService.php) لا تستدعي connect.php
|
||||
// أصلاً، فلا تتأثر بهذه البوّابة ولا يمكن أن تُقفل على نفسها.
|
||||
//
|
||||
// 2026-08-07: وُسِّعت البوّابة لتشمل serviceapp/ أيضاً. كان 34 من 39 ملفاً
|
||||
// هناك بلا أي فحص دور — بينها getDriverByPhone و getPassengersByPhone و
|
||||
// update_complaint — أي أن أي حامل JWT صالح (راكب عادي) يقرأ بيانات
|
||||
// السائقين والركّاب ويغلق الشكاوى. تحققنا أن siro_rider و siro_driver لا
|
||||
// يستدعيان أي نقطة تحت serviceapp/، فالإغلاق لا يكسر تطبيقات الجوال.
|
||||
//
|
||||
// ⚠️ لكل مجلد أدواره: تسجيل دخول تطبيق الخدمة (Admin/jwtService.php:51)
|
||||
// يصدر الدور 'service' لا 'admin'. فرضُ admin على serviceapp/ كان
|
||||
// سيمنع كل موظفي خدمة العملاء من كل نقاطهم.
|
||||
$gatedDirs = array_filter([
|
||||
realpath(__DIR__ . '/Admin') => ['admin', 'super_admin'],
|
||||
realpath(__DIR__ . '/serviceapp') => ['service', 'admin', 'super_admin'],
|
||||
], fn($k) => $k !== false, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
$script = realpath($_SERVER['SCRIPT_FILENAME'] ?? '');
|
||||
|
||||
$gateRoles = null;
|
||||
if ($script) {
|
||||
foreach ($gatedDirs as $dir => $roles) {
|
||||
if (str_starts_with($script, $dir . DIRECTORY_SEPARATOR)) {
|
||||
$gateRoles = $roles;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($gateRoles !== null) {
|
||||
// نقاط تحت Admin/ تستدعيها تطبيقات الجوال بأدوار غير إدارية.
|
||||
// تم التحقق من مواضع الاستدعاء الفعلية في siro_rider و siro_driver:
|
||||
// errorApp.php → التطبيقان يبلّغان عن الأخطاء
|
||||
// sendEmailToDrivertransaction.php → تطبيق السائق
|
||||
// بقية روابط Admin/ المعرّفة في التطبيقين غير مستدعاة إطلاقاً.
|
||||
$adminGateExempt = [
|
||||
'errorApp.php',
|
||||
'sendEmailToDrivertransaction.php',
|
||||
];
|
||||
|
||||
if (!in_array(basename($script), $adminGateExempt, true)
|
||||
&& !in_array($role, $gateRoles, true)
|
||||
) {
|
||||
securityLog('Admin gate blocked non-admin request', [
|
||||
'script' => basename($script),
|
||||
'role' => $role,
|
||||
'user_id' => $user_id ?? 'unknown',
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
]);
|
||||
http_response_code(403);
|
||||
exit(json_encode([
|
||||
'status' => 'failure',
|
||||
'message' => 'Forbidden. Admin access required.',
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 3. Database Connection
|
||||
try {
|
||||
|
||||
@@ -79,7 +79,15 @@ class RateLimiter
|
||||
// ── بناء معرّف المستخدم ────────────────────────────────────
|
||||
public static function identifier(?string $userId = null): string
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
// كان REMOTE_ADDR وحده، وهو خلف دوكر دائماً عنوان الوسيط نفسه
|
||||
// (172.24.0.1) — فيتشارك كل المستخدمين دلواً واحداً، ومحاولات دخول
|
||||
// فاشلة من عميل واحد تحجب الجميع بـ 429 (ظهر ذلك في سجل الإنتاج).
|
||||
// clientIp() تستخرج العميل الحقيقي من X-Forwarded-For ولا تثق به
|
||||
// إلا إذا كان REMOTE_ADDR وسيطاً موثوقاً.
|
||||
$ip = function_exists('clientIp')
|
||||
? clientIp()
|
||||
: ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
|
||||
return $userId ? "{$ip}:{$userId}" : $ip;
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,15 @@ class Database
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_PERSISTENT => true,
|
||||
// الاتصالات الدائمة أُوقفت: كانت تُنتج "Packets out of order.
|
||||
// Expected 1 received 0" بتكرار في الإنتاج — وهو عرض معروف لإعادة
|
||||
// استخدام اتصال دائم تُرك في حالة غير نظيفة، أو أغلقه MySQL عند
|
||||
// wait_timeout بينما ما زال PHP يعدّه حياً. والأسوأ أن التحذير
|
||||
// يُطبع قبل الرؤوس فيُفسد الاستجابة بـ "headers already sent".
|
||||
//
|
||||
// الكلفة اتصال جديد لكل طلب، وهي مقبولة هنا: MySQL على شبكة دوكر
|
||||
// نفسها و max-connections=300. الاستقرار أولى من هذا التوفير.
|
||||
PDO::ATTR_PERSISTENT => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||
PDO::ATTR_TIMEOUT => 10,
|
||||
];
|
||||
|
||||
@@ -371,9 +371,48 @@ class SiroGeminiService {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
return trim(preg_replace('/```html|```/', '', $text));
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a driver support inquiry acting as Siro's smart assistant.
|
||||
*/
|
||||
public function answerSupportInquiry(
|
||||
string $question,
|
||||
array $driverContext,
|
||||
string $model = 'gemini-flash-lite-latest'
|
||||
): ?string {
|
||||
if (!$this->apiKey) return null;
|
||||
|
||||
$contextJson = json_encode($driverContext, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$prompt = "
|
||||
أنت مساعد الدعم الفني الذكي لتطبيق 'سيرو' (Siro) لنقل الركاب.
|
||||
وظيفتك مساعدة السائقين (الكباتن) والإجابة على استفساراتهم باحترافية، ودقة، وبلهجة ودية ومهذبة جداً.
|
||||
|
||||
بيانات السائق الحالية (سياق مهم للإجابة على سؤاله):
|
||||
$contextJson
|
||||
|
||||
سؤال السائق:
|
||||
\"$question\"
|
||||
|
||||
المطلوب منك:
|
||||
1. الإجابة مباشرة على سؤال السائق بناءً على السياق المعطى إذا كان يخص رحلاته أو رصيده المذكور في السياق.
|
||||
2. إذا سأل عن إجراءات عامة، أجب كخبير في سيرو.
|
||||
3. اجعل الإجابة ودية وواضحة جداً وقصيرة.
|
||||
4. الرد يكون باللغة العربية حصراً.
|
||||
5. لا تذكر تفاصيل تقنية (مثل json أو database)، بل تحدث كإنسان يقدم الدعم.
|
||||
";
|
||||
|
||||
$prompt .= "\n\nالرجاء إرجاع الإجابة بصيغة JSON فقط كالتالي:\n{\n \"reply\": \"نص الإجابة هنا\"\n}";
|
||||
|
||||
$response = $this->callGemini($prompt, $model);
|
||||
if ($response && isset($response['reply'])) {
|
||||
return $response['reply'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,3 +288,74 @@ function normalizePhone(string $phone): string
|
||||
return $d; // رقم خارج النطاق — يُعاد كما هو
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* إخفاء جزء من رقم الهاتف قبل عرضه لدور لا يملك صلاحية رؤيته كاملاً.
|
||||
*
|
||||
* نموذج الصلاحيات في اللوحة: `admin` يراقب بأرقام مُخفاة جزئياً،
|
||||
* و`super_admin` وحده يرى الرقم كاملاً.
|
||||
*
|
||||
* يُبقي مقدّمة الدولة وآخر رقمين حتى يبقى الرقم مميّزاً للمتابعة التشغيلية
|
||||
* دون كشف هوية صاحبه.
|
||||
*/
|
||||
function maskPhone(?string $phone): ?string
|
||||
{
|
||||
if ($phone === null || $phone === '') return $phone;
|
||||
|
||||
$d = preg_replace('/\D+/', '', $phone);
|
||||
$len = strlen($d);
|
||||
if ($len < 5) return str_repeat('*', max(0, $len));
|
||||
|
||||
$head = substr($d, 0, 3);
|
||||
$tail = substr($d, -2);
|
||||
return $head . str_repeat('*', $len - 5) . $tail;
|
||||
}
|
||||
|
||||
/**
|
||||
* عنوان العميل الحقيقي خلف الوسطاء.
|
||||
*
|
||||
* سلسلة الطلب: العميل ← nginx المضيف ← nginx الحاوية ← php-fpm، فيصل
|
||||
* REMOTE_ADDR دائماً كعنوان الوسيط. الاعتماد عليه وحده جعل RateLimiter يضع
|
||||
* كل المستخدمين في دلو واحد، فمحاولات دخول فاشلة من عميل واحد تحجب الجميع.
|
||||
*
|
||||
* X-Forwarded-For قابل للتزوير من العميل، لذا لا نثق به إلا إذا كان
|
||||
* REMOTE_ADDR نفسه وسيطاً موثوقاً (شبكات خاصة/لوكال). خلاف ذلك نعيد
|
||||
* REMOTE_ADDR كما هو.
|
||||
*/
|
||||
function clientIp(): string
|
||||
{
|
||||
$remote = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
|
||||
$isTrustedProxy = $remote !== 'unknown' && (
|
||||
filter_var(
|
||||
$remote,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) === false
|
||||
);
|
||||
|
||||
if (!$isTrustedProxy) {
|
||||
return $remote;
|
||||
}
|
||||
|
||||
$forwarded = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
||||
if ($forwarded === '') {
|
||||
return $remote;
|
||||
}
|
||||
|
||||
// أول عنوان عام في السلسلة هو العميل؛ نتخطّى العناوين الخاصة التي
|
||||
// أضافها الوسطاء.
|
||||
foreach (explode(',', $forwarded) as $candidate) {
|
||||
$candidate = trim($candidate);
|
||||
if ($candidate === '') continue;
|
||||
if (filter_var(
|
||||
$candidate,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
|
||||
) !== false) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $remote;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
|
||||
// Get the values from the request
|
||||
$driver_id = filterRequest("driver_id");
|
||||
$assured = filterRequest("assured"); // إذا كانت قيمة حساسة يجب تشفيرها
|
||||
$health_insurance_provider = filterRequest("health_insurance_provider"); // إذا كانت حساسة، شفرها
|
||||
// المسار القديم للتأمين الصحي. المسار الجديد (خطط، أهلية، أقساط) في
|
||||
// plans.php و subscribe.php و get.php — هذا يبقى لمن سُجّل هنا سابقاً.
|
||||
|
||||
// إذا تحتاج تشفير، فعّل التالي:
|
||||
// $assured = $encryptionHelper->encryptData($assured);
|
||||
// $health_insurance_provider = $encryptionHelper->encryptData($health_insurance_provider);
|
||||
// الهوية من الـJWT لا من الطلب. كان الملف يقرأ driver_id من المدخلات
|
||||
// بلا أي فحص، فأي حامل رمز صالح يكتب سجل تأمين باسم أي سائق.
|
||||
$driver_id = $user_id ?? '';
|
||||
if (empty($driver_id) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
// SQL using bind parameters
|
||||
$assured = filterRequest("assured");
|
||||
$health_insurance_provider = filterRequest("health_insurance_provider");
|
||||
|
||||
// upsert لا insert: العمود driver_id عليه مفتاح فريد، فأي حفظ ثانٍ
|
||||
// لنفس السائق كان يفشل بخرق المفتاح — والحفظ الثاني هو الحالة
|
||||
// الطبيعية (تعديل مزوّد التأمين) لا الاستثناء.
|
||||
$sql = "INSERT INTO `driver_health_assurance` (
|
||||
`driver_id`,
|
||||
`assured`,
|
||||
@@ -20,7 +26,10 @@ $sql = "INSERT INTO `driver_health_assurance` (
|
||||
:driver_id,
|
||||
:assured,
|
||||
:health_insurance_provider
|
||||
)";
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`assured` = VALUES(`assured`),
|
||||
`health_insurance_provider` = VALUES(`health_insurance_provider`)";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->bindParam(':driver_id', $driver_id);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/**
|
||||
* driver_assurance/cancel.php — إلغاء وثيقة التأمين
|
||||
*
|
||||
* الإلغاء يوقف الأقساط القادمة ولا يمسّ الماضي: القيود المستحقّة تبقى
|
||||
* مستحقّة. تصفيرها عند الإلغاء كانت ستجعل الإلغاء وسيلة للتهرّب من
|
||||
* أيام تغطية استفاد منها السائق فعلاً.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/eligibility.php';
|
||||
require_once __DIR__ . '/../obligations/functions.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$policy = assuranceActivePolicy($con, $driverId);
|
||||
if (!$policy) {
|
||||
jsonError('لا توجد وثيقة نشطة');
|
||||
}
|
||||
|
||||
$reason = filterRequest('reason') ?: 'بطلب السائق';
|
||||
|
||||
// الوثيقة والالتزام يُغلقان معاً. التزام يبقى نشطاً بعد إلغاء وثيقته
|
||||
// يعني قسطاً يُقيَّد كل ليلة على سائقٍ بلا تغطية — عكس الثغرة السابقة
|
||||
// تماماً، وأسوأ منها: هناك مال يضيع على الشركة، وهنا مال يُؤخذ ظلماً.
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
// الشرط على driver_id إلى جانب المعرّف: الوثيقة أتت من استعلام
|
||||
// مقيَّد بالسائق أصلاً، لكن الشرط هنا يجعل الاستعلام آمناً بذاته
|
||||
// لو أعيد استعماله يوماً في سياق آخر.
|
||||
$st = $con->prepare("
|
||||
UPDATE driver_insurance_policies
|
||||
SET status = 'cancelled', ended_at = CURDATE(), cancel_reason = ?
|
||||
WHERE id = ? AND driver_id = ? AND status = 'active'
|
||||
");
|
||||
$st->execute([mb_substr($reason, 0, 255), $policy['id'], $driverId]);
|
||||
|
||||
if ($st->rowCount() === 0) {
|
||||
$con->rollBack();
|
||||
jsonError('تعذّر إلغاء الوثيقة');
|
||||
}
|
||||
|
||||
obligationClose($con, $driverId, $policy['code'], $reason);
|
||||
|
||||
$con->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
error_log('[assurance/cancel] ' . $e->getMessage());
|
||||
jsonError('Server error');
|
||||
}
|
||||
|
||||
// المستحق يُقرأ من الدفتر الموحّد لا من دفتر التأمين القديم: القديم
|
||||
// مجمَّد بعد الترحيل، وقراءته تعني رقماً لا يتغيّر مهما سدّد السائق.
|
||||
$pending = obligationPendingTotal($con, $driverId, $policy['code']);
|
||||
|
||||
error_log("[assurance] أُلغيت وثيقة #{$policy['id']} للسائق $driverId");
|
||||
|
||||
jsonSuccess([
|
||||
'cancelled' => true,
|
||||
'pending_balance' => $pending,
|
||||
'currency' => $policy['currency'],
|
||||
], $pending > 0
|
||||
? 'أُلغيت الوثيقة. يبقى عليك مستحق أقساط سابقة قدره ' . $pending . ' ' . $policy['currency']
|
||||
: 'أُلغيت الوثيقة');
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* driver_assurance/eligibility.php — حساب أهلية السائق للتأمين
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* قرار المالك: التأمين ليس خدمة تُمنح لكل من سجّل، بل مكافأة استمرار.
|
||||
* لا يستحقّه السائق إلا بعد عدد رحلات مكتملة وتقييم جيد.
|
||||
*
|
||||
* المنطق التجاري خلفه: القسط كلفة متكررة تتحمّلها الشركة عن السائق أو
|
||||
* تخصمها من أرباحه. منحه لمن قد يختفي بعد أسبوع يحوّل الأداة من
|
||||
* ولاء إلى نزيف — والسائق الذي أتمّ مئتي رحلة بتقييم جيد أثبت أنه
|
||||
* باقٍ، وهو بالضبط من نريد قفله معنا.
|
||||
*
|
||||
* الأرقام في جدول الخطط لا في هذا الملف: الشريك سيغيّرها، ومصر تختلف
|
||||
* عن الأردن، وتعديل صف أرخص من نشر إصدار.
|
||||
*/
|
||||
|
||||
/**
|
||||
* يقيس السائق مقابل شروط خطة.
|
||||
*
|
||||
* @return array{
|
||||
* eligible: bool,
|
||||
* rides: int, rating: float, account_days: int,
|
||||
* reasons: string[]
|
||||
* }
|
||||
*/
|
||||
function assuranceCheckEligibility(PDO $con, string $driverId, array $plan): array
|
||||
{
|
||||
$rides = 0;
|
||||
$rating = 0.0;
|
||||
$days = 0;
|
||||
|
||||
try {
|
||||
// الرحلات المكتملة فقط. الحالة مكتوبة بحرفين مختلفين في
|
||||
// الجدول تاريخياً ('Finished' و'finished')، وإغفال أحدهما
|
||||
// يُنقص عدّاد السائق إلى النصف تقريباً.
|
||||
$st = $con->prepare("
|
||||
SELECT COUNT(*) FROM ride
|
||||
WHERE driver_id = ? AND status IN ('Finished', 'finished')
|
||||
");
|
||||
$st->execute([$driverId]);
|
||||
$rides = (int) $st->fetchColumn();
|
||||
|
||||
$st = $con->prepare("SELECT AVG(rating) FROM ratingDriver WHERE driver_id = ?");
|
||||
$st->execute([$driverId]);
|
||||
$avg = $st->fetchColumn();
|
||||
// سائق بلا تقييمات ليس سيئاً — لكنه أيضاً غير مثبَت. عدد
|
||||
// الرحلات المطلوب يضمن أن تقييماته وصلت على أي حال.
|
||||
$rating = $avg === null || $avg === false ? 0.0 : round((float) $avg, 2);
|
||||
|
||||
$st = $con->prepare("SELECT DATEDIFF(NOW(), created_at) FROM driver WHERE id = ? LIMIT 1");
|
||||
$st->execute([$driverId]);
|
||||
$d = $st->fetchColumn();
|
||||
$days = ($d === false || $d === null) ? 0 : (int) $d;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[assurance] تعذّر حساب الأهلية: ' . $e->getMessage());
|
||||
return [
|
||||
'eligible' => false, 'rides' => 0, 'rating' => 0.0, 'account_days' => 0,
|
||||
'reasons' => ['تعذّر التحقق حالياً'],
|
||||
];
|
||||
}
|
||||
|
||||
$minRides = (int) ($plan['min_completed_rides'] ?? 200);
|
||||
$minRating = (float) ($plan['min_rating'] ?? 4.50);
|
||||
$minDays = (int) ($plan['min_account_days'] ?? 30);
|
||||
|
||||
// نجمع كل الأسباب لا أوّلها: السائق يستحق أن يعرف كل ما ينقصه
|
||||
// دفعةً واحدة، لا أن يعالج شرطاً فيُفاجأ بالتالي.
|
||||
$reasons = [];
|
||||
if ($rides < $minRides) {
|
||||
$reasons[] = "أكمل $minRides رحلة (لديك $rides)";
|
||||
}
|
||||
if ($rating < $minRating) {
|
||||
$reasons[] = "تقييمك يجب ألّا يقل عن $minRating (تقييمك " . ($rating ?: '—') . ')';
|
||||
}
|
||||
if ($days < $minDays) {
|
||||
$reasons[] = "مضيّ $minDays يوماً على حسابك (مضى $days)";
|
||||
}
|
||||
|
||||
return [
|
||||
'eligible' => empty($reasons),
|
||||
'rides' => $rides,
|
||||
'rating' => $rating,
|
||||
'account_days' => $days,
|
||||
'reasons' => $reasons,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* الوثيقة النشطة للسائق مع بيانات خطتها، أو null.
|
||||
*/
|
||||
function assuranceActivePolicy(PDO $con, string $driverId): ?array
|
||||
{
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
SELECT p.*, pl.code, pl.name_ar AS plan_name, pl.premium, pl.currency,
|
||||
pl.billing_cycle, pl.coverage_summary,
|
||||
pr.name AS provider_name, pr.name_ar AS provider_name_ar
|
||||
FROM driver_insurance_policies p
|
||||
JOIN insurance_plans pl ON pl.id = p.plan_id
|
||||
JOIN insurance_providers pr ON pr.id = pl.provider_id
|
||||
WHERE p.driver_id = ? AND p.status = 'active'
|
||||
ORDER BY p.id DESC LIMIT 1
|
||||
");
|
||||
$st->execute([$driverId]);
|
||||
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
} catch (PDOException $e) {
|
||||
error_log('[assurance] تعذّر جلب الوثيقة: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
/**
|
||||
* driver_assurance/get.php — حالة تأمين السائق
|
||||
*
|
||||
* كان هذا الملف فارغاً (صفر بايت) — يُستدعى فيرد بلا شيء.
|
||||
*
|
||||
* يرد بالوثيقة النشطة، وملخّص دفتر أقساطها، وآخر القيود. الدفتر جزء
|
||||
* من الرد لا نقطة منفصلة: السائق الذي يُخصم من أرباحه يجب أن يرى
|
||||
* مقابل ماذا في الشاشة نفسها، وإلا صار الخصم مفاجأة تُفقد الثقة.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/eligibility.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$policy = assuranceActivePolicy($con, $driverId);
|
||||
|
||||
if (!$policy) {
|
||||
jsonSuccess([
|
||||
'has_policy' => false,
|
||||
'policy' => null,
|
||||
'ledger' => ['pending_total' => 0, 'settled_total' => 0, 'currency' => null],
|
||||
'entries' => [],
|
||||
], 'لا توجد وثيقة نشطة');
|
||||
}
|
||||
|
||||
$summary = ['pending_total' => 0.0, 'settled_total' => 0.0,
|
||||
'currency' => $policy['currency']];
|
||||
$entries = [];
|
||||
|
||||
// المصدر هو الدفتر الموحّد لا دفتر التأمين القديم (المجمَّد بعد ترحيل
|
||||
// 2026_08_08). المفتاح صار (السائق + رمز المنتج) بدل رقم الوثيقة:
|
||||
// الدفتر الموحّد لا يعرف الوثائق، يعرف التزامات.
|
||||
try {
|
||||
// المستحق من `amount_remaining` والمحصَّل من `amount_collected`:
|
||||
// قيدٌ حُصِّل نصفه يظهر في الرقمين معاً بحصّته الصحيحة، بينما جمع
|
||||
// `amount` حسب الحالة كان سيعدّه كاملاً في أحدهما ويكذب في كليهما.
|
||||
$st = $con->prepare("
|
||||
SELECT COALESCE(SUM(amount_remaining), 0) AS pending_total,
|
||||
COALESCE(SUM(amount_collected), 0) AS settled_total
|
||||
FROM obligation_ledger
|
||||
WHERE driver_id = ? AND product_code = ?
|
||||
");
|
||||
$st->execute([$driverId, $policy['code']]);
|
||||
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
|
||||
$summary['pending_total'] = (float) $row['pending_total'];
|
||||
$summary['settled_total'] = (float) $row['settled_total'];
|
||||
}
|
||||
|
||||
// آخر ثلاثين قيداً: كافية لشهر يومي، وتمنع رداً ينمو بلا حد.
|
||||
$st = $con->prepare("
|
||||
SELECT charge_date, amount, amount_collected, amount_remaining,
|
||||
currency, status, note
|
||||
FROM obligation_ledger
|
||||
WHERE driver_id = ? AND product_code = ?
|
||||
ORDER BY charge_date DESC
|
||||
LIMIT 30
|
||||
");
|
||||
$st->execute([$driverId, $policy['code']]);
|
||||
$entries = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[assurance/get] تعذّرت قراءة الدفتر: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'has_policy' => true,
|
||||
'policy' => [
|
||||
'id' => (int) $policy['id'],
|
||||
'plan' => $policy['plan_name'],
|
||||
'provider' => $policy['provider_name_ar'] ?: $policy['provider_name'],
|
||||
'policy_number' => $policy['policy_number'],
|
||||
'coverage' => $policy['coverage_summary'],
|
||||
'billing_cycle' => $policy['billing_cycle'],
|
||||
'premium' => (float) $policy['premium'],
|
||||
'currency' => $policy['currency'],
|
||||
'started_at' => $policy['started_at'],
|
||||
'last_charged_on' => $policy['last_charged_on'],
|
||||
],
|
||||
'ledger' => $summary,
|
||||
'entries' => $entries,
|
||||
], 'success');
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* driver_assurance/plans.php — الخطط المتاحة وأهلية السائق لكلٍّ منها
|
||||
*
|
||||
* نرد بكل الخطط لا المؤهَّل لها فقط، ومع كل واحدة سبب عدم الأهلية إن
|
||||
* وُجد. إخفاء ما لا يستحقه السائق يجعل التأمين مجهولاً عنده؛ إظهاره
|
||||
* مع «ينقصك ٤٠ رحلة» يحوّله إلى هدف يسعى إليه — وهذا هو مقصد الشرط
|
||||
* أصلاً: ربط السائق بالمنصّة.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/eligibility.php';
|
||||
require_once __DIR__ . '/../obligations/functions.php';
|
||||
|
||||
// الهوية من الـJWT لا من الطلب — الأهلية معلومة عن السائق نفسه،
|
||||
// وقراءتها بمعرّف مُرسَل تكشف أرقام غيره.
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$country = getenv('APP_COUNTRY') ?: 'Jordan';
|
||||
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
SELECT pl.*, pr.name AS provider_name, pr.name_ar AS provider_name_ar
|
||||
FROM insurance_plans pl
|
||||
JOIN insurance_providers pr ON pr.id = pl.provider_id
|
||||
WHERE pl.is_active = 1 AND pr.is_active = 1 AND pr.country = ?
|
||||
ORDER BY pl.premium ASC
|
||||
");
|
||||
$st->execute([$country]);
|
||||
$plans = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[assurance/plans] ' . $e->getMessage());
|
||||
jsonError('Server error');
|
||||
}
|
||||
|
||||
$current = assuranceActivePolicy($con, $driverId);
|
||||
|
||||
// نداء واحد للمحفظة لكل الشاشة: الملخّص عن السائق لا عن الخطة.
|
||||
// بدونه كانت الشاشة تعرض «مؤهَّل» ثم يُرفض الطلب عند الضغط — وهو أسوأ
|
||||
// من الرفض المبكر: يعد السائق بشيء ثم يسحبه.
|
||||
$incomeSummary = obligationWalletIncomeSummary($driverId);
|
||||
|
||||
$out = [];
|
||||
foreach ($plans as $plan) {
|
||||
$check = assuranceCheckEligibility($con, $driverId, $plan);
|
||||
|
||||
// بوابة الائتمان تُقاس على منتج الالتزام المقابل للخطة. غيابه يعني
|
||||
// خطة لم تُرحَّل بعد — لا نحجبها ولا ندّعي أهليةً لها، بل نتركها
|
||||
// لحكم شروط الخطة وحدها ونسجّل الحالة.
|
||||
$product = obligationProduct($con, $plan['code']);
|
||||
if ($product) {
|
||||
$gate = obligationCheckWalletIncome($product, $driverId, $incomeSummary);
|
||||
if (!$gate['eligible']) {
|
||||
$check['eligible'] = false;
|
||||
$check['reasons'] = array_merge($check['reasons'], $gate['reasons']);
|
||||
}
|
||||
} else {
|
||||
error_log("[assurance/plans] لا منتج التزام للخطة {$plan['code']}");
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'id' => (int) $plan['id'],
|
||||
'code' => $plan['code'],
|
||||
'name' => $plan['name_ar'],
|
||||
'description' => $plan['description_ar'],
|
||||
'provider' => $plan['provider_name_ar'] ?: $plan['provider_name'],
|
||||
'billing_cycle' => $plan['billing_cycle'],
|
||||
'premium' => (float) $plan['premium'],
|
||||
'currency' => $plan['currency'],
|
||||
'coverage' => $plan['coverage_summary'],
|
||||
'eligible' => $check['eligible'],
|
||||
'missing' => $check['reasons'],
|
||||
'is_current' => $current && (int) $current['plan_id'] === (int) $plan['id'],
|
||||
// تقدّم السائق نحو الشرط — رقم يفهمه بلا شرح.
|
||||
'progress_rides' => $check['rides'],
|
||||
'required_rides' => (int) $plan['min_completed_rides'],
|
||||
];
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'plans' => $out,
|
||||
'has_policy' => (bool) $current,
|
||||
'driver_rating' => $current ? (float) $current['rating_at_signup'] : null,
|
||||
], 'success');
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* driver_assurance/subscribe.php — اشتراك السائق في خطة تأمين
|
||||
*
|
||||
* الأهلية تُحسب هنا من جديد لا يُوثق بما أرسله التطبيق: الشاشة قد تكون
|
||||
* مفتوحة منذ ساعة، وقد يُعدَّل الطلب يدوياً. النقطة التي تمنح تغطية
|
||||
* مالية لا تصدّق عميلها.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/eligibility.php';
|
||||
require_once __DIR__ . '/../obligations/functions.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$planId = (int) (filterRequest('plan_id', 'int') ?: 0);
|
||||
if ($planId <= 0) {
|
||||
jsonError('plan_id is required');
|
||||
}
|
||||
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
SELECT pl.*, pr.is_active AS provider_active
|
||||
FROM insurance_plans pl
|
||||
JOIN insurance_providers pr ON pr.id = pl.provider_id
|
||||
WHERE pl.id = ? AND pl.is_active = 1 LIMIT 1
|
||||
");
|
||||
$st->execute([$planId]);
|
||||
$plan = $st->fetch(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[assurance/subscribe] ' . $e->getMessage());
|
||||
jsonError('Server error');
|
||||
}
|
||||
|
||||
if (!$plan || (int) $plan['provider_active'] !== 1) {
|
||||
jsonError('Plan not available');
|
||||
}
|
||||
|
||||
// وثيقة نشطة قائمة: لا نشترك مرتين ولا نستبدل بصمت. تبديل الخطة
|
||||
// قرار مالي — يمرّ بإلغاء صريح ثم اشتراك جديد، ليبقى في السجل أثر.
|
||||
if (assuranceActivePolicy($con, $driverId)) {
|
||||
jsonError('لديك وثيقة تأمين نشطة بالفعل. ألغِها أولاً لتغيير الخطة.');
|
||||
}
|
||||
|
||||
$check = assuranceCheckEligibility($con, $driverId, $plan);
|
||||
if (!$check['eligible']) {
|
||||
jsonError('لم تستوفِ شروط هذه الخطة بعد: ' . implode('، ', $check['reasons']));
|
||||
}
|
||||
|
||||
// بوابة الائتمان تُفحص هنا صراحةً رغم أن `obligationOpen` تفحصها أيضاً:
|
||||
// الفحص هناك حارسٌ أخير يرمي استثناءً، ورسالته تصل للسائق كخطأ عام.
|
||||
// السائق الذي يُرفض طلبه يستحق أن يعرف السبب وما يفعله ليستوفيه.
|
||||
$product = obligationProduct($con, $plan['code']);
|
||||
if (!$product) {
|
||||
error_log("[assurance/subscribe] منتج الالتزام مفقود للخطة {$plan['code']}");
|
||||
jsonError('تعذّر تفعيل الوثيقة حالياً');
|
||||
}
|
||||
|
||||
$walletGate = obligationCheckWalletIncome($product, $driverId);
|
||||
if (!$walletGate['eligible']) {
|
||||
jsonError(
|
||||
'التأمين يُخصم قسطه من أرباحك في المحفظة، ولذلك يشترط أن تمرّ أرباحك بها: '
|
||||
. implode('، ', $walletGate['reasons'])
|
||||
);
|
||||
}
|
||||
|
||||
// كتابتان في معاملة واحدة: الوثيقة (السجل التأميني الذي يقرأه التطبيق)
|
||||
// والالتزام (ما يراه محرك القيد والتسوية). وثيقة بلا التزام تعني تغطية
|
||||
// تُمنح ولا يُحصَّل قسطها أبداً — وهي بالضبط الثغرة التي جاء هذا المحرك
|
||||
// ليغلقها، فلا يجوز أن نعيد فتحها من باب الاشتراكات الجديدة.
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
$con->prepare("
|
||||
INSERT INTO driver_insurance_policies
|
||||
(driver_id, plan_id, status, started_at, rides_at_signup, rating_at_signup)
|
||||
VALUES (?, ?, 'active', CURDATE(), ?, ?)
|
||||
")->execute([$driverId, $planId, $check['rides'], $check['rating']]);
|
||||
|
||||
$policyId = (int) $con->lastInsertId();
|
||||
|
||||
obligationOpen($con, $driverId, $plan['code'], [
|
||||
'cycle_amount' => (float) $plan['premium'],
|
||||
'rides' => $check['rides'],
|
||||
'rating' => $check['rating'],
|
||||
]);
|
||||
|
||||
$con->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
error_log('[assurance/subscribe] تعذّر إنشاء الوثيقة: ' . $e->getMessage());
|
||||
jsonError('تعذّر تفعيل الوثيقة حالياً');
|
||||
}
|
||||
|
||||
error_log("[assurance] السائق $driverId اشترك في الخطة {$plan['code']} — وثيقة #$policyId");
|
||||
|
||||
jsonSuccess([
|
||||
'policy_id' => $policyId,
|
||||
'plan' => $plan['name_ar'],
|
||||
'premium' => (float) $plan['premium'],
|
||||
'currency' => $plan['currency'],
|
||||
'billing_cycle' => $plan['billing_cycle'],
|
||||
// أول قسط يُقيَّد في تشغيل الكرون القادم لا الآن: الاشتراك ليس
|
||||
// دفعاً، والقيد يجب أن يكون له تاريخ واضح يوافق دورة الفوترة.
|
||||
'starts_on' => date('Y-m-d'),
|
||||
], 'تم تفعيل وثيقة التأمين');
|
||||
@@ -4,7 +4,7 @@
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
|
||||
// 1. فرض المصادقة (Auth Required)
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<?php
|
||||
//encrypt_decrypt.php
|
||||
// ⚠️ هذا الملف للتوافقية فقط. استخدم core/Security/EncryptionHelper.php للتشفير الجديد
|
||||
require_once realpath(__DIR__ . '/../vendor/autoload.php');
|
||||
// vendor يقع داخل backend/ لا فوقه. المسار السابق '/../vendor/autoload.php'
|
||||
// يشير إلى /var/www/vendor غير الموجود، و realpath() تُعيد false عندئذٍ،
|
||||
// فيصبح الاستدعاء require_once('') ويسقط بخطأ قاتل يُحلّ من مجلد العمل
|
||||
// الحالي — وهو ما ظهر في السجل كـ "Failed opening required ''".
|
||||
$autoloadPath = __DIR__ . '/vendor/autoload.php';
|
||||
if (!file_exists($autoloadPath)) {
|
||||
$autoloadPath = __DIR__ . '/../vendor/autoload.php';
|
||||
}
|
||||
if (file_exists($autoloadPath)) {
|
||||
require_once $autoloadPath;
|
||||
}
|
||||
|
||||
require_once 'load_env.php';
|
||||
$siteUser = get_current_user();
|
||||
@@ -34,6 +44,12 @@ if (!$key) {
|
||||
$iv = getenv('initializationVector'); // 16 bytes
|
||||
|
||||
|
||||
// الصنف نفسه معرَّف أيضاً في core/Security/EncryptionHelper.php، وأي ملف
|
||||
// يحمّل bootstrap.php ثم يصل إلى هنا كان ينهار بـ "Cannot declare class
|
||||
// EncryptionHelper, because the name is already in use" — وهو ما أسقط
|
||||
// blacklist_manager.php. نُبقي أول تعريف محمَّل (النسخة الحديثة في core)
|
||||
// ونتخطّى هذه النسخة القديمة المتروكة للتوافقية.
|
||||
if (!class_exists('EncryptionHelper', false)) {
|
||||
class EncryptionHelper {
|
||||
private $key;
|
||||
private $iv;
|
||||
@@ -150,6 +166,7 @@ class EncryptionHelper {
|
||||
return $decrypted;
|
||||
}
|
||||
}
|
||||
} // نهاية حارس class_exists
|
||||
// ✅ Load the key and IV from .env or use default values
|
||||
|
||||
// ✅ Ensure the lengths are correct
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/admin/courier_settlement.php — تسوية أرباح سائق التوصيل
|
||||
//
|
||||
// قرار المالك: الأجرة تتراكم وتُصرف **دورياً مع مقاصّة دَين النقد**.
|
||||
//
|
||||
// لماذا المقاصّة: في الطلب النقدي يحصّل السائق grand_total كاملاً من
|
||||
// الزبون، فيصير مديناً للمنصة بحصة المطعم والعمولة. صرف أجرته منفصلاً
|
||||
// عن هذا الدَين يعني أن تدفع له بينما هو يحمل مالك.
|
||||
//
|
||||
// الصافي = أجور التوصيل − ديون النقد − المُرحَّل من دورة سابقة
|
||||
//
|
||||
// عمليتان في نقطة واحدة:
|
||||
// action=preview (الافتراضي) — يحسب ويعرض بلا أي أثر
|
||||
// action=execute — يثبّت الدورة ويصرف إن كان الصافي موجباً
|
||||
//
|
||||
// الفصل مقصود: صرف المال يجب أن يسبقه اطّلاع، لا أن يكون أثراً جانبياً
|
||||
// لفتح شاشة.
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
require_once __DIR__ . '/../../ride/pricing/pricing_helper.php';
|
||||
|
||||
$courierId = filterRequest('courier_id');
|
||||
$periodStart = filterRequest('period_start');
|
||||
$periodEnd = filterRequest('period_end');
|
||||
$action = filterRequest('action') === 'execute' ? 'execute' : 'preview';
|
||||
|
||||
requireFoodFields(['courier_id', 'period_start', 'period_end']);
|
||||
|
||||
// عملة الطعام تتبع دولة التشغيل. الأردن أولاً — والثابت يبقى مقروءاً من
|
||||
// البيئة حتى لا يتحول إلى رقم مدفون عند التوسع.
|
||||
$currency = strtoupper((string) (getenv('FOOD_CURRENCY') ?: 'JOD'));
|
||||
|
||||
try {
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ١) القيود غير المسوّاة في الفترة
|
||||
//
|
||||
// settlement_id IS NULL هو الضمانة ضد الاحتساب المزدوج: قيد ضُمّ
|
||||
// لدورة سابقة لا يعود في هذه.
|
||||
// ══════════════════════════════════════════════════════════
|
||||
$st = $food_con->prepare("
|
||||
SELECT p.id, p.type, p.amount
|
||||
FROM food_order_payments p
|
||||
JOIN food_orders o ON o.id = p.order_id
|
||||
WHERE o.courier_id = ?
|
||||
AND o.delivered_at BETWEEN ? AND ?
|
||||
AND p.settlement_id IS NULL
|
||||
AND p.type IN ('courier_payout', 'cash_settlement')
|
||||
");
|
||||
$st->execute([$courierId, $periodStart, $periodEnd]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$payoutTotal = 0;
|
||||
$cashDebt = 0;
|
||||
$payoutIds = [];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$payoutIds[] = (int) $r['id'];
|
||||
if ($r['type'] === 'courier_payout') {
|
||||
$payoutTotal += (int) $r['amount'];
|
||||
} else {
|
||||
$cashDebt += (int) $r['amount'];
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ٢) المُرحَّل من دورة سابقة سالبة
|
||||
// ══════════════════════════════════════════════════════════
|
||||
$stCarry = $food_con->prepare("
|
||||
SELECT COALESCE(SUM(-net_amount), 0)
|
||||
FROM food_courier_settlements
|
||||
WHERE courier_id = ? AND status = 'carried'
|
||||
");
|
||||
$stCarry->execute([$courierId]);
|
||||
$carriedOver = (int) $stCarry->fetchColumn();
|
||||
|
||||
$net = $payoutTotal - $cashDebt - $carriedOver;
|
||||
|
||||
$stCount = $food_con->prepare("
|
||||
SELECT COUNT(*) FROM food_orders
|
||||
WHERE courier_id = ? AND status = 'delivered'
|
||||
AND delivered_at BETWEEN ? AND ?
|
||||
");
|
||||
$stCount->execute([$courierId, $periodStart, $periodEnd]);
|
||||
$ordersCount = (int) $stCount->fetchColumn();
|
||||
|
||||
$summary = [
|
||||
'courier_id' => $courierId,
|
||||
'period_start' => $periodStart,
|
||||
'period_end' => $periodEnd,
|
||||
'orders_count' => $ordersCount,
|
||||
'payout_total' => $payoutTotal,
|
||||
'cash_debt_total' => $cashDebt,
|
||||
'carried_over' => $carriedOver,
|
||||
'net_amount' => $net,
|
||||
'net_decimal' => foodSmallestUnitToDecimal(abs($net)) * ($net < 0 ? -1 : 1),
|
||||
'currency' => $currency,
|
||||
'entries_count' => count($payoutIds),
|
||||
];
|
||||
|
||||
// ── المعاينة تتوقف هنا ──────────────────────────────────
|
||||
if ($action === 'preview') {
|
||||
$summary['action'] = 'preview';
|
||||
$summary['would_pay'] = $net > 0;
|
||||
jsonSuccess($summary, 'Settlement preview');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ٣) التنفيذ
|
||||
// ══════════════════════════════════════════════════════════
|
||||
if (empty($payoutIds) && $carriedOver === 0) {
|
||||
jsonError('No unsettled entries for this courier and period');
|
||||
}
|
||||
|
||||
$status = $net > 0 ? 'draft' : 'carried';
|
||||
|
||||
$food_con->beginTransaction();
|
||||
|
||||
$ins = $food_con->prepare("
|
||||
INSERT INTO food_courier_settlements
|
||||
(courier_id, period_start, period_end, orders_count,
|
||||
payout_total, cash_debt_total, carried_over, net_amount,
|
||||
currency, status, executed_by, executed_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW())
|
||||
");
|
||||
$ins->execute([
|
||||
$courierId, $periodStart, $periodEnd, $ordersCount,
|
||||
$payoutTotal, $cashDebt, $carriedOver, $net,
|
||||
$currency, $status, $food_admin_id ?: 'admin',
|
||||
]);
|
||||
$settlementId = (int) $food_con->lastInsertId();
|
||||
|
||||
// وسم القيود قبل الصرف: لو انقطع التنفيذ بعد التحويل وقبل الوسم،
|
||||
// لأُعيد صرفها في الدورة التالية.
|
||||
if (!empty($payoutIds)) {
|
||||
$ph = implode(',', array_fill(0, count($payoutIds), '?'));
|
||||
$food_con->prepare("
|
||||
UPDATE food_order_payments
|
||||
SET settlement_id = ?, status = 'success'
|
||||
WHERE id IN ($ph)
|
||||
")->execute(array_merge([$settlementId], $payoutIds));
|
||||
}
|
||||
|
||||
// الدورات السالبة المُرحَّلة استُهلكت في هذا الحساب — نغلقها حتى لا
|
||||
// تُطرح مرة أخرى في الدورة القادمة.
|
||||
if ($carriedOver > 0) {
|
||||
$food_con->prepare("
|
||||
UPDATE food_courier_settlements
|
||||
SET status = 'settled_forward', note = CONCAT(COALESCE(note,''), ' → #', ?)
|
||||
WHERE courier_id = ? AND status = 'carried' AND id <> ?
|
||||
")->execute([$settlementId, $courierId, $settlementId]);
|
||||
}
|
||||
|
||||
$food_con->commit();
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ٤) التحويل — بعد الـcommit لا داخله
|
||||
// ══════════════════════════════════════════════════════════
|
||||
$transferCode = null;
|
||||
|
||||
if ($net > 0) {
|
||||
$transferCode = foodPayCourier(
|
||||
$courierId,
|
||||
foodSmallestUnitToDecimal($net),
|
||||
$settlementId
|
||||
);
|
||||
|
||||
$food_con->prepare("
|
||||
UPDATE food_courier_settlements
|
||||
SET status = ?, transfer_code = ?
|
||||
WHERE id = ?
|
||||
")->execute([$transferCode === 200 ? 'paid' : 'failed', $transferCode, $settlementId]);
|
||||
|
||||
if ($transferCode !== 200) {
|
||||
error_log("[FOOD][SETTLEMENT] MONEY: تسوية #$settlementId بقيمة $net"
|
||||
. " للسائق $courierId لم تصل (رمز=$transferCode)");
|
||||
}
|
||||
}
|
||||
|
||||
$summary['settlement_id'] = $settlementId;
|
||||
$summary['status'] = $net > 0
|
||||
? ($transferCode === 200 ? 'paid' : 'failed')
|
||||
: 'carried';
|
||||
$summary['transfer_code'] = $transferCode;
|
||||
|
||||
jsonSuccess($summary, 'Settlement executed');
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if (isset($food_con) && $food_con->inTransaction()) $food_con->rollBack();
|
||||
error_log("[FOOD][SETTLEMENT] " . $e->getMessage());
|
||||
jsonError('DB Error', 500);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* يودع صافي التسوية في محفظة السائق.
|
||||
*
|
||||
* يستخدم driverWallet/add_s2s_reward.php — وهو العقد الذي أكّدناه عملياً
|
||||
* في تعويض عدم الحضور ورسوم الإلغاء وتعويضات الشكاوى. تعليق delivered.php
|
||||
* يقول إن العقد المؤكد لتحويلات سائق↔سائق فقط، وهذا لم يعد صحيحاً.
|
||||
*
|
||||
* paymentID مشتق من رقم التسوية فيمنع ازدواج الصرف عند إعادة المحاولة.
|
||||
*/
|
||||
function foodPayCourier(string $courierId, float $amount, int $settlementId): int
|
||||
{
|
||||
$walletServer = foodWalletServerUrl();
|
||||
$url = "$walletServer/v2/main/ride/driverWallet/add_s2s_reward.php";
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'driverID' => $courierId,
|
||||
'paymentID' => "food_settlement_$settlementId",
|
||||
'amount' => $amount,
|
||||
'paymentMethod' => 'food_courier_settlement',
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
|
||||
],
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $code;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ $food_admin_id = (string)($decoded->user_id ?? '');
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
// السبب الحقيقي في اللوج فقط — الرد يبقى عاماً.
|
||||
error_log('[FOOD] DB connection failed in ' . basename(__FILE__) . ': ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
|
||||
exit;
|
||||
|
||||
@@ -31,6 +31,8 @@ $food_passenger_id = (string)($decoded->user_id ?? '');
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
// السبب الحقيقي في اللوج فقط — الرد يبقى عاماً.
|
||||
error_log('[FOOD] DB connection failed in ' . basename(__FILE__) . ': ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
|
||||
exit;
|
||||
|
||||
@@ -28,6 +28,8 @@ $food_courier_id = (string)($decoded->user_id ?? '');
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
// السبب الحقيقي في اللوج فقط — الرد يبقى عاماً.
|
||||
error_log('[FOOD] DB connection failed in ' . basename(__FILE__) . ': ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
|
||||
exit;
|
||||
|
||||
@@ -30,6 +30,8 @@ $limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
// السبب الحقيقي في اللوج فقط — الرد يبقى عاماً.
|
||||
error_log('[FOOD] DB connection failed in ' . basename(__FILE__) . ': ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
|
||||
exit;
|
||||
|
||||
@@ -4,8 +4,11 @@ require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.latitude AS merchant_lat,
|
||||
m.longitude AS merchant_lng, m.address AS merchant_address, o.delivery_fee,
|
||||
o.delivery_address, o.delivery_lat, o.delivery_lng, o.created_at,
|
||||
m.longitude AS merchant_lng, m.address AS merchant_address, m.avg_prep_minutes,
|
||||
o.delivery_fee, o.items_total, o.service_fee, o.discount, o.grand_total, o.payment_method,
|
||||
o.customer_note, o.delivery_address, o.delivery_lat, o.delivery_lng,
|
||||
o.created_at, o.ready_at, o.courier_assigned_at, o.picked_up_at,
|
||||
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count,
|
||||
CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS visible_address
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
@@ -20,6 +23,15 @@ foreach ($orders as &$o) {
|
||||
unset($o['delivery_address']);
|
||||
$o['delivery_address'] = $o['visible_address'];
|
||||
unset($o['visible_address']);
|
||||
|
||||
$o['items_count'] = (int)$o['items_count'];
|
||||
// نقداً: السائق يحصّل grand_total من الزبون ويحتفظ بأجرته (delivery_fee)،
|
||||
// والباقي يبقى ديناً عليه حتى التسوية — نُظهر الرقمين صراحةً في التطبيق
|
||||
// كي لا يجتهد السائق في الحساب على باب الزبون.
|
||||
$o['cash_to_collect'] = $o['payment_method'] === 'cash' ? (int)$o['grand_total'] : 0;
|
||||
$o['courier_owes'] = $o['payment_method'] === 'cash'
|
||||
? (int)$o['grand_total'] - (int)$o['delivery_fee']
|
||||
: 0;
|
||||
}
|
||||
unset($o);
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// food/courier/call_customer.php — السائق يتصل بالزبون عبر قناة مقنّعة
|
||||
// لا رقم هاتف يُعرض لأي طرف: نفتح جلسة WebRTC ونُشعر الزبون بمعرّف الجلسة فقط.
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
|
||||
|
||||
if (!foodOrderAllowsCall($order)) {
|
||||
// بعد التسليم أو قبل الإسناد لا حاجة تشغيلية للاتصال — والقناة تُقفل
|
||||
jsonError('Calling is only allowed while the delivery is active', 403);
|
||||
}
|
||||
|
||||
if (foodCallQuotaExceeded($orderId, 'courier')) {
|
||||
jsonError('Too many call attempts for this order', 429);
|
||||
}
|
||||
|
||||
$session = foodCreateCallSession($orderId, $food_courier_id, (string)$order['passenger_id']);
|
||||
if (!$session) jsonError('Call service unavailable', 502);
|
||||
|
||||
// إشعار صامت للزبون عبر موضوع FCM الخاص به — نفس القناة المستعملة في وحدة
|
||||
// الطعام أصلاً (ممنوع Database::get('main') هنا لجلب توكن الجهاز).
|
||||
// الاسم المعروض عام عمداً: هوية السائق الحقيقية لا تُكشف للزبون.
|
||||
foodSendNotificationToPassenger(
|
||||
(string)$order['passenger_id'],
|
||||
'مكالمة واردة',
|
||||
'سائق التوصيل يتصل بك',
|
||||
[
|
||||
// تطبيق الراكب يفرز الإشعارات بـ data['category'] — ونُبقي 'type'
|
||||
// للتوافق مع بقية حمولات وحدة الطعام.
|
||||
'category' => 'incoming_call',
|
||||
'type' => 'incoming_call',
|
||||
'session_id' => $session['session_id'],
|
||||
'caller_name' => 'سائق التوصيل',
|
||||
'caller_avatar' => '',
|
||||
'ride_id' => 'food_' . $orderId,
|
||||
'food_order_id' => (string)$orderId,
|
||||
]
|
||||
);
|
||||
|
||||
appLog("[FOOD][CALL] courier {$food_courier_id} → passenger (order {$orderId})", 'INFO');
|
||||
|
||||
jsonSuccess([
|
||||
'session_id' => $session['session_id'],
|
||||
'expires_in' => $session['expires_in'],
|
||||
]);
|
||||
@@ -24,9 +24,15 @@ if ($order['payment_method'] === 'wallet') {
|
||||
)->execute([$orderId, $courierOwed]);
|
||||
}
|
||||
|
||||
// قيد أرباح السائق (أجرة التوصيل) — مُتراكم، يُسوَّى عبر دورة تسوية منفصلة
|
||||
// (لا نقتطع فعلياً من/إلى محفظة السائق هنا: عقد S2S المؤكد فقط لتحويلات
|
||||
// سائق↔سائق (driverWallet/transfer.php) لا لإيداع أرباح من المنصة مباشرة).
|
||||
// قيد أرباح السائق (أجرة التوصيل) — مُتراكم عمداً بقرار المالك: الأجرة
|
||||
// تُصرف دورياً مع مقاصّة دَين النقد، لا فورياً عند كل تسليم. صرفها منفصلة
|
||||
// عن الدَين يعني الدفع للسائق بينما هو يحمل مال المنصة من الطلبات النقدية.
|
||||
//
|
||||
// التسوية في food/admin/courier_settlement.php.
|
||||
//
|
||||
// (تصحيح لملاحظة سابقة هنا: عقد S2S لإيداع أرباح من المنصة **موجود
|
||||
// ومؤكد** — driverWallet/add_s2s_reward.php، وهو مستخدم في تعويض عدم
|
||||
// الحضور ورسوم الإلغاء وتعويضات الشكاوى.)
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'courier_payout',?,'pending')"
|
||||
)->execute([$orderId, (int)$order['delivery_fee']]);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
// food/courier/earnings.php — ملخص أرباح التوصيل للسائق (اليوم/الأسبوع/الشهر)
|
||||
// المبالغ من food_orders.delivery_fee للطلبات المسلَّمة فقط — نفس ما يُقيَّد في
|
||||
// food_order_payments (courier_payout)، والتسوية الفعلية خارج نطاق هذا الملف.
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$sums = $food_con->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total_orders,
|
||||
COALESCE(SUM(delivery_fee),0) AS total_earnings,
|
||||
COALESCE(SUM(CASE WHEN DATE(delivered_at)=CURDATE() THEN delivery_fee END),0) AS today_earnings,
|
||||
COUNT(CASE WHEN DATE(delivered_at)=CURDATE() THEN 1 END) AS today_orders,
|
||||
COALESCE(SUM(CASE WHEN delivered_at >= (NOW() - INTERVAL 7 DAY) THEN delivery_fee END),0) AS week_earnings,
|
||||
COUNT(CASE WHEN delivered_at >= (NOW() - INTERVAL 7 DAY) THEN 1 END) AS week_orders,
|
||||
COALESCE(SUM(CASE WHEN delivered_at >= (NOW() - INTERVAL 30 DAY) THEN delivery_fee END),0) AS month_earnings,
|
||||
COUNT(CASE WHEN delivered_at >= (NOW() - INTERVAL 30 DAY) THEN 1 END) AS month_orders
|
||||
FROM food_orders
|
||||
WHERE courier_id=? AND status='delivered'"
|
||||
);
|
||||
$sums->execute([$food_courier_id]);
|
||||
$row = $sums->fetch() ?: [];
|
||||
|
||||
// ديون التحصيل النقدي غير المسوّاة — السائق يحتاج يعرف كم عليه قبل ما يفاجأ بخصم
|
||||
$owedSt = $food_con->prepare(
|
||||
"SELECT COALESCE(SUM(p.amount),0) AS owed
|
||||
FROM food_order_payments p
|
||||
JOIN food_orders o ON o.id = p.order_id
|
||||
WHERE o.courier_id=? AND p.type='cash_settlement' AND p.status='pending'"
|
||||
);
|
||||
$owedSt->execute([$food_courier_id]);
|
||||
|
||||
// آخر 7 أيام مفصّلة — للرسم البياني في التطبيق
|
||||
$dailySt = $food_con->prepare(
|
||||
"SELECT DATE(delivered_at) AS day, COUNT(*) AS orders_count, COALESCE(SUM(delivery_fee),0) AS earnings
|
||||
FROM food_orders
|
||||
WHERE courier_id=? AND status='delivered' AND delivered_at >= (NOW() - INTERVAL 7 DAY)
|
||||
GROUP BY DATE(delivered_at)
|
||||
ORDER BY day DESC"
|
||||
);
|
||||
$dailySt->execute([$food_courier_id]);
|
||||
|
||||
jsonSuccess([
|
||||
'today_earnings' => (int)($row['today_earnings'] ?? 0),
|
||||
'today_orders' => (int)($row['today_orders'] ?? 0),
|
||||
'week_earnings' => (int)($row['week_earnings'] ?? 0),
|
||||
'week_orders' => (int)($row['week_orders'] ?? 0),
|
||||
'month_earnings' => (int)($row['month_earnings'] ?? 0),
|
||||
'month_orders' => (int)($row['month_orders'] ?? 0),
|
||||
'total_earnings' => (int)($row['total_earnings'] ?? 0),
|
||||
'total_orders' => (int)($row['total_orders'] ?? 0),
|
||||
'pending_cash_owed' => (int)($owedSt->fetch()['owed'] ?? 0),
|
||||
'daily' => array_map(static fn($d) => [
|
||||
'day' => $d['day'],
|
||||
'orders_count' => (int)$d['orders_count'],
|
||||
'earnings' => (int)$d['earnings'],
|
||||
], $dailySt->fetchAll()),
|
||||
]);
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// food/courier/history.php — سجل طلبات التوصيل المنتهية لهذا السائق
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$limit = (int)(filterRequest('limit', 'int') ?: 20);
|
||||
$offset = (int)(filterRequest('offset', 'int') ?: 0);
|
||||
$limit = max(1, min($limit, 50));
|
||||
$offset = max(0, $offset);
|
||||
|
||||
// العنوان النصّي للزبون لا يُعاد في السجل — انتهت الحاجة التشغيلية إليه بالتسليم،
|
||||
// ونفس قاعدة الحجب المطبَّقة في active.php.
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.address AS merchant_address,
|
||||
o.delivery_fee, o.grand_total, o.payment_method, o.rating,
|
||||
o.courier_assigned_at, o.picked_up_at, o.delivered_at, o.created_at,
|
||||
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count,
|
||||
TIMESTAMPDIFF(MINUTE, o.courier_assigned_at, o.delivered_at) AS duration_minutes
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.courier_id = ? AND o.status = 'delivered'
|
||||
ORDER BY o.delivered_at DESC
|
||||
LIMIT $limit OFFSET $offset"
|
||||
);
|
||||
$st->execute([$food_courier_id]);
|
||||
|
||||
$orders = array_map(static function (array $o): array {
|
||||
$o['items_count'] = (int)$o['items_count'];
|
||||
$o['delivery_fee'] = (int)$o['delivery_fee'];
|
||||
$o['grand_total'] = (int)$o['grand_total'];
|
||||
$o['duration_minutes'] = $o['duration_minutes'] === null ? null : (int)$o['duration_minutes'];
|
||||
return $o;
|
||||
}, $st->fetchAll());
|
||||
|
||||
jsonSuccess(['orders' => $orders, 'limit' => $limit, 'offset' => $offset]);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// food/courier/location.php — بثّ موقع السائق أثناء مهمة توصيل نشطة
|
||||
//
|
||||
// خصوصية: الموقع يُقبل فقط ما دام الطلب في courier_assigned/picked_up، ويُخزَّن
|
||||
// في Redis بعمر 90 ثانية لا في قاعدة البيانات. بمجرد التسليم يرفض الخادم أي
|
||||
// تحديث، وينتهي آخر موقع تلقائياً — فلا يتحول التتبّع إلى مراقبة للسائق بعد
|
||||
// انتهاء عمله، ولا يبقى أثر دائم لتحركاته في وحدة الطعام.
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$lat = filterRequest('lat');
|
||||
$lng = filterRequest('lng');
|
||||
|
||||
if (!$orderId || $lat === null || $lng === null) {
|
||||
jsonError('order_id, lat and lng are required');
|
||||
}
|
||||
|
||||
$lat = (float)$lat;
|
||||
$lng = (float)$lng;
|
||||
if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180 || ($lat === 0.0 && $lng === 0.0)) {
|
||||
jsonError('Invalid coordinates');
|
||||
}
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
|
||||
|
||||
if (!in_array($order['status'], ['courier_assigned', 'picked_up'], true)) {
|
||||
jsonError('Location sharing is only allowed while the delivery is active', 403);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'order_id' => $orderId,
|
||||
'lat' => round($lat, 6),
|
||||
'lng' => round($lng, 6),
|
||||
'heading' => filterRequest('heading') !== null ? (float)filterRequest('heading') : null,
|
||||
'ts' => time(),
|
||||
];
|
||||
|
||||
if ($redis) {
|
||||
// مصدر مسار الاحتياط: تطبيق الراكب يسحبه من order/courier_location.php
|
||||
$redis->setex("food:order:{$orderId}:courier_pos", 90, json_encode($payload));
|
||||
}
|
||||
|
||||
// المسار اللحظي — غرفة الزبون على سوكيت الطعام
|
||||
foodPushToSocket('courier_location', array_merge($payload, [
|
||||
'passenger_id' => (string)$order['passenger_id'],
|
||||
]));
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'ts' => $payload['ts']]);
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
// food/courier/order_details.php — تفاصيل مهمة توصيل واحدة (شاشة المهمة عند السائق)
|
||||
// أصناف الطلب + تفصيل المبالغ + هاتف المطعم للتواصل أثناء الاستلام
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
// يفشل لو لم يكن الطلب مُسنداً لهذا السائق — لا وصول لطلبات الآخرين
|
||||
foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.id, o.status, o.merchant_id, o.items_total, o.delivery_fee, o.service_fee, o.discount,
|
||||
o.grand_total, o.payment_method, o.customer_note, o.delivery_lat, o.delivery_lng,
|
||||
o.created_at, o.ready_at, o.courier_assigned_at, o.picked_up_at, o.delivered_at,
|
||||
CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS delivery_address,
|
||||
m.name_ar AS merchant_name_ar, m.address AS merchant_address, m.latitude AS merchant_lat,
|
||||
m.longitude AS merchant_lng, m.avg_prep_minutes
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.id = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$orderId]);
|
||||
$order = $st->fetch();
|
||||
if (!$order) jsonError('Order not found', 404);
|
||||
|
||||
$itemsSt = $food_con->prepare(
|
||||
"SELECT name_ar_snapshot, quantity, unit_price, line_total, option_price_json
|
||||
FROM food_order_items WHERE order_id=? ORDER BY id ASC"
|
||||
);
|
||||
$itemsSt->execute([$orderId]);
|
||||
$items = array_map(static function (array $i): array {
|
||||
return [
|
||||
'name_ar' => $i['name_ar_snapshot'],
|
||||
'quantity' => (int)$i['quantity'],
|
||||
'unit_price' => (int)$i['unit_price'],
|
||||
'line_total' => (int)$i['line_total'],
|
||||
'options' => $i['option_price_json'] ? json_decode($i['option_price_json'], true) : null,
|
||||
];
|
||||
}, $itemsSt->fetchAll());
|
||||
|
||||
// هاتف المطعم — يُعاد فقط أثناء المهمة النشطة (قبل التسليم)، لا في السجل
|
||||
$merchantPhone = null;
|
||||
if (in_array($order['status'], ['courier_assigned', 'picked_up'], true)) {
|
||||
$phoneSt = $food_con->prepare(
|
||||
"SELECT phone FROM food_merchant_users
|
||||
WHERE merchant_id=? AND is_active=1 ORDER BY role='owner' DESC, id ASC LIMIT 1"
|
||||
);
|
||||
$phoneSt->execute([$order['merchant_id']]);
|
||||
$merchantPhone = $phoneSt->fetch()['phone'] ?? null;
|
||||
}
|
||||
|
||||
// ملاحظة: لا هاتف للزبون هنا — قاعدة siro_food معزولة ولا تحمل بيانات الراكب،
|
||||
// وممنوع Database::get('main') داخل backend/food/. التواصل مع الزبون يبقى عبر
|
||||
// عنوان التسليم والملاحظة، إلى أن تُضاف قناة اتصال مقنّعة كما في الرحلات.
|
||||
$order['items'] = $items;
|
||||
$order['items_count'] = array_sum(array_column($items, 'quantity'));
|
||||
$order['merchant_phone'] = $merchantPhone;
|
||||
$order['cash_to_collect'] = $order['payment_method'] === 'cash' ? (int)$order['grand_total'] : 0;
|
||||
$order['courier_owes'] = $order['payment_method'] === 'cash'
|
||||
? (int)$order['grand_total'] - (int)$order['delivery_fee']
|
||||
: 0;
|
||||
|
||||
jsonSuccess(['order' => $order]);
|
||||
@@ -5,15 +5,25 @@
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT a.order_id, a.offered_at, o.merchant_id, m.name_ar AS merchant_name_ar,
|
||||
m.latitude AS merchant_lat, m.longitude AS merchant_lng, o.delivery_fee,
|
||||
o.delivery_lat, o.delivery_lng
|
||||
"SELECT a.order_id, a.offered_at
|
||||
FROM food_courier_assignments a
|
||||
JOIN food_orders o ON o.id = a.order_id
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE a.courier_id = ? AND a.status = 'offered' AND a.offered_at > (NOW() - INTERVAL 20 SECOND)
|
||||
AND o.status = 'ready' AND o.courier_id IS NULL
|
||||
ORDER BY a.offered_at ASC"
|
||||
);
|
||||
$st->execute([$food_courier_id]);
|
||||
|
||||
jsonSuccess(['offers' => $st->fetchAll()]);
|
||||
// نفس حمولة مسار السوكيت حرفياً (foodBuildCourierOfferPayload) — شاشة العرض
|
||||
// عند السائق واحدة، فلا يجوز أن تختلف الحقول حسب المسار الذي وصل منه العرض.
|
||||
$offers = [];
|
||||
foreach ($st->fetchAll() as $row) {
|
||||
$payload = foodBuildCourierOfferPayload((int)$row['order_id']);
|
||||
if (!$payload) continue;
|
||||
$offers[] = array_merge(
|
||||
['order_id' => (int)$row['order_id'], 'offered_at' => $row['offered_at'], 'offer_ttl_seconds' => 20],
|
||||
$payload
|
||||
);
|
||||
}
|
||||
|
||||
jsonSuccess(['offers' => $offers]);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// food/courier/status.php — حالة السائق في وحدة التوصيل عند فتح التبويب
|
||||
// (وضع التوصيل مخزَّن في Redis لا في التطبيق — بدون هذا النداء يفتح التطبيق
|
||||
// على "مغلق" بينما السائق ما زال فعلياً في food:couriers:opted_in ويستقبل عروضاً)
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$enabled = false;
|
||||
if ($redis) {
|
||||
$enabled = (bool)$redis->sIsMember('food:couriers:opted_in', $food_courier_id);
|
||||
}
|
||||
|
||||
$activeSt = $food_con->prepare(
|
||||
"SELECT COUNT(*) AS c FROM food_orders
|
||||
WHERE courier_id=? AND status IN ('courier_assigned','picked_up')"
|
||||
);
|
||||
$activeSt->execute([$food_courier_id]);
|
||||
$activeCount = (int)($activeSt->fetch()['c'] ?? 0);
|
||||
|
||||
$todaySt = $food_con->prepare(
|
||||
"SELECT COUNT(*) AS orders_count, COALESCE(SUM(delivery_fee),0) AS earnings
|
||||
FROM food_orders
|
||||
WHERE courier_id=? AND status='delivered' AND DATE(delivered_at)=CURDATE()"
|
||||
);
|
||||
$todaySt->execute([$food_courier_id]);
|
||||
$today = $todaySt->fetch();
|
||||
|
||||
jsonSuccess([
|
||||
'delivery_mode_enabled' => $enabled,
|
||||
'active_orders_count' => $activeCount,
|
||||
'today_orders_count' => (int)$today['orders_count'],
|
||||
'today_earnings' => (int)$today['earnings'],
|
||||
]);
|
||||
+148
-3
@@ -179,6 +179,91 @@ function foodSendNotificationToPassenger(string $passengerId, string $title, str
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// قناة اتصال مقنّعة بين السائق والزبون (WebRTC — بلا أرقام هواتف)
|
||||
// ------------------------------------------------------------
|
||||
// لا نكشف رقم أي طرف للآخر إطلاقاً: خادم الإشارات (Node) يفتح جلسة
|
||||
// بمعرّف عشوائي قصير العمر، والطرفان ينضمّان إليها بـ session_id فقط.
|
||||
// نستعمل نفس بنية مكالمات الرحلات (VOICE_CALL_SERVER_URL) لكن بمرجع
|
||||
// جلسة مُسمّى food_{order_id} حتى تبقى سجلات الطعام مميّزة عن الرحلات.
|
||||
// ============================================================
|
||||
|
||||
// حد إساءة الاستخدام: عدد محاولات فتح جلسة لكل طلب/طرف خلال ساعتين
|
||||
const FOOD_CALL_MAX_PER_ORDER = 10;
|
||||
|
||||
function foodCallQuotaExceeded(int $orderId, string $callerRole): bool
|
||||
{
|
||||
global $redis;
|
||||
if (!$redis) return false;
|
||||
|
||||
$key = "food:call_quota:{$orderId}:{$callerRole}";
|
||||
$count = (int)$redis->incr($key);
|
||||
if ($count === 1) $redis->expire($key, 7200);
|
||||
|
||||
if ($count > FOOD_CALL_MAX_PER_ORDER) {
|
||||
appLog("[FOOD][CALL] quota exceeded order=$orderId role=$callerRole", 'WARNING');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* يفتح جلسة مكالمة على خادم الإشارات ويعيد session_id.
|
||||
* يعيد null عند أي فشل — المُنادي هو من يقرر رسالة الخطأ للمستخدم.
|
||||
*/
|
||||
function foodCreateCallSession(int $orderId, string $courierId, string $passengerId): ?array
|
||||
{
|
||||
$url = (getenv('VOICE_CALL_SERVER_URL') ?: 'https://calls.intaleqapp.com') . '/sessions';
|
||||
$apiKey = getenv('VOICE_CALL_API_KEY') ?: '';
|
||||
if (!$apiKey) {
|
||||
appLog('[FOOD][CALL] VOICE_CALL_API_KEY missing — cannot create session', 'ERROR');
|
||||
return null;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
// خادم الإشارات يعامل ride_id كمعرّف نصّي مبهم — نُميّز طلبات الطعام
|
||||
'ride_id' => 'food_' . $orderId,
|
||||
'driver_id' => $courierId,
|
||||
'passenger_id' => $passengerId,
|
||||
]),
|
||||
CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
appLog("[FOOD][CALL] signaling server failed (HTTP $httpCode): $result", 'ERROR');
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode((string)$result, true);
|
||||
if (!isset($data['session_id'])) {
|
||||
appLog('[FOOD][CALL] invalid response schema from signaling server', 'ERROR');
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'session_id' => (string)$data['session_id'],
|
||||
'expires_in' => (int)($data['expires_in'] ?? 60),
|
||||
];
|
||||
}
|
||||
|
||||
// المكالمة مسموحة فقط داخل النافذة التشغيلية للطلب — بعد التسليم تُقفل القناة
|
||||
function foodOrderAllowsCall(array $order): bool
|
||||
{
|
||||
return in_array($order['status'], ['courier_assigned', 'picked_up'], true)
|
||||
&& !empty($order['courier_id']);
|
||||
}
|
||||
|
||||
// ── Session مطعم (هاتف+كلمة مرور — مستقلة عن JWT، مثل transit) ──
|
||||
|
||||
function foodCreateMerchantSession(int $merchantUserId, int $merchantId): string
|
||||
@@ -423,7 +508,22 @@ function foodFindNearbyCouriers(float $lat, float $lng, float $radiusKm = 5, int
|
||||
$optedIn = $redis->sMembers('food:couriers:opted_in');
|
||||
if (!$optedIn) return [];
|
||||
|
||||
return array_values(array_intersect($nearby, $optedIn));
|
||||
$candidates = array_values(array_intersect($nearby, $optedIn));
|
||||
if (!$candidates) return [];
|
||||
|
||||
// مهمة واحدة في الوقت الواحد: التطبيق يُعلن السائق مشغولاً في نظام الرحلات
|
||||
// فور إسناد طلب له، فيخرج من geo:drivers:available وحده. لكن ذلك يعتمد على
|
||||
// وصول تحديث موقع، فقد يتأخر ثوانٍ. هذا الفحص هو الضمانة القاطعة: لا يُعرض
|
||||
// طلب ثانٍ على سائق يحمل طلباً نشطاً مهما تأخّر تحديث المجموعة.
|
||||
$placeholders = implode(',', array_fill(0, count($candidates), '?'));
|
||||
$busySt = Database::get('food')->prepare(
|
||||
"SELECT DISTINCT courier_id FROM food_orders
|
||||
WHERE courier_id IN ($placeholders) AND status IN ('courier_assigned','picked_up')"
|
||||
);
|
||||
$busySt->execute($candidates);
|
||||
$busy = array_column($busySt->fetchAll(), 'courier_id');
|
||||
|
||||
return array_values(array_diff($candidates, $busy));
|
||||
}
|
||||
|
||||
function foodOfferOrderToCourier(int $orderId, string $courierId): void
|
||||
@@ -433,11 +533,56 @@ function foodOfferOrderToCourier(int $orderId, string $courierId): void
|
||||
"INSERT INTO food_courier_assignments (order_id, courier_id, status) VALUES (?,?,'offered')"
|
||||
)->execute([$orderId, $courierId]);
|
||||
|
||||
// العرض يُدفع كاملاً عبر السوكيت: شاشة العرض عند السائق تُبنى من هذه الحمولة
|
||||
// مباشرة بلا نداء HTTP إضافي (مهلة العرض 20 ثانية لا تحتمل round-trip زائد).
|
||||
$offer = foodBuildCourierOfferPayload($orderId);
|
||||
|
||||
// ملاحظة: لا FCM هنا عمداً — توكن جهاز السائق في جدول driverToken على main DB،
|
||||
// وممنوع Database::get('main') داخل backend/food/ (نفس قاعدة transit). الإشعار
|
||||
// اللحظي يمر فقط عبر socket_food (السائق متصل بسوكيته أثناء وضع التوصيل)،
|
||||
// والتطبيق يعتمد أيضاً على courier/active.php كـ polling fallback عند الانقطاع.
|
||||
foodPushToSocket('courier_offer', ['order_id' => $orderId, 'courier_id' => $courierId]);
|
||||
// والتطبيق يعتمد أيضاً على courier/pending_offers.php كـ polling fallback عند الانقطاع.
|
||||
foodPushToSocket('courier_offer', array_merge(
|
||||
['order_id' => $orderId, 'courier_id' => $courierId, 'offer_ttl_seconds' => 20],
|
||||
$offer
|
||||
));
|
||||
}
|
||||
|
||||
// حمولة عرض التوصيل — مصدر واحد يستخدمه السوكيت و pending_offers.php معاً
|
||||
// حتى لا تختلف الحقول بين المسار اللحظي ومسار الاحتياط.
|
||||
function foodBuildCourierOfferPayload(int $orderId): array
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare(
|
||||
"SELECT o.id, o.merchant_id, o.delivery_fee, o.grand_total, o.payment_method,
|
||||
o.delivery_lat, o.delivery_lng, o.items_total, o.created_at,
|
||||
m.name_ar AS merchant_name_ar, m.address AS merchant_address,
|
||||
m.latitude AS merchant_lat, m.longitude AS merchant_lng,
|
||||
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.id = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$orderId]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) return [];
|
||||
|
||||
// العنوان النصّي للزبون لا يُرسل في العرض — يظهر فقط بعد القبول (active.php).
|
||||
// نرسل الإحداثيات فقط لحساب المسافة/الاتجاه في شاشة العرض.
|
||||
return [
|
||||
'merchant_id' => (int)$row['merchant_id'],
|
||||
'merchant_name_ar' => $row['merchant_name_ar'],
|
||||
'merchant_address' => $row['merchant_address'],
|
||||
'merchant_lat' => (float)$row['merchant_lat'],
|
||||
'merchant_lng' => (float)$row['merchant_lng'],
|
||||
'delivery_lat' => (float)$row['delivery_lat'],
|
||||
'delivery_lng' => (float)$row['delivery_lng'],
|
||||
'delivery_fee' => (int)$row['delivery_fee'],
|
||||
'items_count' => (int)$row['items_count'],
|
||||
'payment_method' => $row['payment_method'],
|
||||
// نقداً: السائق سيحصّل هذا المبلغ من الزبون — معلومة قرار أساسية قبل القبول
|
||||
'cash_to_collect' => $row['payment_method'] === 'cash' ? (int)$row['grand_total'] : 0,
|
||||
'order_created_at' => $row['created_at'],
|
||||
];
|
||||
}
|
||||
|
||||
// SET NX EX 20 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- 2026_08_07_courier_settlement.sql — دورة تسوية أرباح سائقي التوصيل
|
||||
--
|
||||
-- قرار المالك: الأجرة **تتراكم وتُصرف دورياً مع مقاصّة دَين النقد**، لا
|
||||
-- تُصرف فوراً عند كل تسليم.
|
||||
--
|
||||
-- لماذا المقاصّة ضرورية: في الطلب النقدي يحصّل السائق grand_total كاملاً
|
||||
-- من الزبون، فيصير مديناً للمنصة بحصة المطعم والعمولة. صرف أجرته منفصلاً
|
||||
-- عن هذا الدَين يعني أن تدفع له بينما هو يحمل مالك.
|
||||
--
|
||||
-- الصافي = مجموع أجور التوصيل − مجموع ديون النقد
|
||||
--
|
||||
-- موجب → يُصرف لمحفظة السائق
|
||||
-- سالب → يبقى ديناً يُرحَّل للدورة التالية (لا نخصم من محفظته آلياً:
|
||||
-- قد تكون فارغة، والخصم القسري يفاجئه ويوقفه عن العمل)
|
||||
--
|
||||
-- ⚠️ يُنفَّذ على قاعدة الطعام (siro_food) لا primary.
|
||||
--
|
||||
-- sirodb siro_food < backend/food/migrations/2026_08_07_courier_settlement.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `food_courier_settlements` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`courier_id` VARCHAR(100) NOT NULL COMMENT 'معرّف السائق (نفس driver.id)',
|
||||
`period_start` DATETIME NOT NULL,
|
||||
`period_end` DATETIME NOT NULL,
|
||||
|
||||
`orders_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`payout_total` BIGINT NOT NULL DEFAULT 0 COMMENT 'مجموع أجور التوصيل المستحقة (أصغر وحدة)',
|
||||
`cash_debt_total` BIGINT NOT NULL DEFAULT 0 COMMENT 'مجموع ما حصّله نقداً ويخص المنصة',
|
||||
`carried_over` BIGINT NOT NULL DEFAULT 0 COMMENT 'دَين مُرحَّل من دورة سابقة (سالب الصافي)',
|
||||
`net_amount` BIGINT NOT NULL DEFAULT 0 COMMENT 'الصافي = payout − debt − carried_over',
|
||||
|
||||
`currency` VARCHAR(5) NOT NULL DEFAULT 'JOD',
|
||||
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'draft'
|
||||
COMMENT 'paid = صُرفت | failed = تعذّر التحويل | carried = سالبة تُرحَّل للدورة القادمة | settled_forward = رُحّلت واستُهلكت في دورة لاحقة | draft',
|
||||
`transfer_code` SMALLINT NULL DEFAULT NULL COMMENT 'رمز HTTP من خادم المحفظة',
|
||||
`executed_by` VARCHAR(100) NULL DEFAULT NULL,
|
||||
`executed_at` DATETIME NULL DEFAULT NULL,
|
||||
`note` VARCHAR(500) NULL DEFAULT NULL,
|
||||
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
-- يمنع توليد دورتين لنفس السائق ونفس الفترة — الازدواج هنا يعني صرفاً مضاعفاً
|
||||
UNIQUE KEY `uniq_courier_period` (`courier_id`, `period_start`, `period_end`),
|
||||
KEY `idx_status` (`status`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='دورات تسوية سائقي التوصيل — أجور مقاصّةً مع ديون النقد';
|
||||
|
||||
-- ربط قيود الدفع بدورة التسوية التي ضمّتها: بدونه لا نعرف ما سُوّي وما لم
|
||||
-- يُسوَّ، فتُحتسب نفس الأجرة مرتين في دورتين متتاليتين.
|
||||
ALTER TABLE `food_order_payments`
|
||||
ADD COLUMN `settlement_id` INT UNSIGNED NULL DEFAULT NULL
|
||||
COMMENT 'دورة التسوية التي ضمّت هذا القيد. NULL = لم يُسوَّ بعد',
|
||||
ADD KEY `idx_settlement` (`settlement_id`, `type`, `status`);
|
||||
|
||||
-- للتراجع:
|
||||
-- DROP TABLE `food_courier_settlements`;
|
||||
-- ALTER TABLE `food_order_payments` DROP COLUMN `settlement_id`, DROP KEY `idx_settlement`;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 2026_08_07_verify_food.sql — فحص حالة ترحيلات الطعام
|
||||
--
|
||||
-- قراءة خالصة لا تُعدّل شيئاً.
|
||||
-- sirodb siro_food < backend/food/migrations/2026_08_07_verify_food.sql
|
||||
|
||||
SELECT 'TABLE food_courier_settlements' AS item,
|
||||
COUNT(*) AS ok FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='food_courier_settlements'
|
||||
UNION ALL SELECT 'food_order_payments.settlement_id', COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='food_order_payments' AND COLUMN_NAME='settlement_id'
|
||||
UNION ALL SELECT 'food_order_payments.idx_settlement', COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='food_order_payments' AND INDEX_NAME='idx_settlement';
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// food/order/call_courier.php — الزبون يتصل بسائق التوصيل عبر قناة مقنّعة
|
||||
// لا رقم هاتف يُعرض لأي طرف — session_id فقط، والقناة تُقفل بانتهاء الطلب.
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||
|
||||
if (!foodOrderAllowsCall($order)) {
|
||||
jsonError('Calling is only allowed while a courier is delivering your order', 403);
|
||||
}
|
||||
|
||||
if (foodCallQuotaExceeded($orderId, 'customer')) {
|
||||
jsonError('Too many call attempts for this order', 429);
|
||||
}
|
||||
|
||||
$courierId = (string)$order['courier_id'];
|
||||
$session = foodCreateCallSession($orderId, $courierId, $food_passenger_id);
|
||||
if (!$session) jsonError('Call service unavailable', 502);
|
||||
|
||||
// السائق يصله التنبيه عبر سوكيت الطعام (غرفة courier_food_{id}) — لا موضوع FCM
|
||||
// خاصاً بكل سائق في النظام، والسوكيت حيّ أصلاً أثناء وضع التوصيل.
|
||||
// الاسم المعروض عام عمداً: هوية الزبون لا تُكشف للسائق.
|
||||
foodPushToSocket('courier_call', [
|
||||
'order_id' => $orderId,
|
||||
'courier_id' => $courierId,
|
||||
'session_id' => $session['session_id'],
|
||||
'caller_name' => 'زبون الطلب #' . $orderId,
|
||||
'expires_in' => $session['expires_in'],
|
||||
]);
|
||||
|
||||
appLog("[FOOD][CALL] passenger {$food_passenger_id} → courier (order {$orderId})", 'INFO');
|
||||
|
||||
jsonSuccess([
|
||||
'session_id' => $session['session_id'],
|
||||
'expires_in' => $session['expires_in'],
|
||||
]);
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
// food/order/courier_location.php — آخر موقع معروف لسائق طلبي (للراكب)
|
||||
//
|
||||
// مسار سحب (polling): تطبيق الراكب لا يحمل عميل سوكيت للطعام حالياً، وهذا
|
||||
// المصدر يكفي لخريطة تتبّع سلسة مع تحديث كل بضع ثوانٍ.
|
||||
// الموقع نفسه ينتهي من Redis خلال 90 ثانية، فبعد التسليم — أو بعد توقف
|
||||
// السائق عن البثّ — تُرجع الواجهة null بلا أي أثر متبقٍ.
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||
|
||||
// لا نكشف موقع السائق إلا خلال نافذة التوصيل — لا قبل الإسناد ولا بعد التسليم
|
||||
if (!in_array($order['status'], ['courier_assigned', 'picked_up'], true)) {
|
||||
jsonSuccess(['status' => $order['status'], 'courier_position' => null]);
|
||||
}
|
||||
|
||||
$position = null;
|
||||
if ($redis) {
|
||||
$raw = $redis->get("food:order:{$orderId}:courier_pos");
|
||||
if ($raw) $position = json_decode($raw, true);
|
||||
}
|
||||
|
||||
// إحداثيات وجهتَي الخريطة — المطعم وعنوان التسليم (كلاهما معروف للزبون أصلاً)
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.delivery_lat, o.delivery_lng, o.delivery_address,
|
||||
m.name_ar AS merchant_name_ar, m.latitude AS merchant_lat, m.longitude AS merchant_lng
|
||||
FROM food_orders o JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.id = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$orderId]);
|
||||
$meta = $st->fetch() ?: [];
|
||||
|
||||
jsonSuccess([
|
||||
'status' => $order['status'],
|
||||
'courier_position' => $position,
|
||||
'merchant' => $meta ? [
|
||||
'name_ar' => $meta['merchant_name_ar'],
|
||||
'lat' => (float)$meta['merchant_lat'],
|
||||
'lng' => (float)$meta['merchant_lng'],
|
||||
] : null,
|
||||
'destination' => $meta ? [
|
||||
'address' => $meta['delivery_address'],
|
||||
'lat' => (float)$meta['delivery_lat'],
|
||||
'lng' => (float)$meta['delivery_lng'],
|
||||
] : null,
|
||||
]);
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* fuel/cancel_voucher.php — إلغاء قسيمة لم تُصرف
|
||||
*
|
||||
* يحرّر المبلغ المحجوز فوراً. بدونه ينتظر السائق انتهاء الصلاحية ليعود
|
||||
* سقفه — وهو ينتظر عند مضخة ليتزوّد بمبلغ آخر.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
// الشرط على driver_id إلى جانب الحالة: الرمز وحده يكفي للعثور على
|
||||
// قسيمة، ولا يجوز أن يُلغي سائقٌ قسيمة غيره برمز وقع بيده.
|
||||
$st = $con->prepare("
|
||||
UPDATE fuel_vouchers
|
||||
SET status = 'cancelled'
|
||||
WHERE driver_id = ? AND status = 'issued'
|
||||
");
|
||||
$st->execute([$driverId]);
|
||||
|
||||
if ($st->rowCount() === 0) {
|
||||
jsonError('لا توجد قسيمة قابلة للإلغاء');
|
||||
}
|
||||
|
||||
$obligation = fuelObligation($con, $driverId);
|
||||
$state = $obligation ? fuelCreditState($con, $obligation) : null;
|
||||
|
||||
jsonSuccess([
|
||||
'cancelled' => true,
|
||||
'available' => $state['available'] ?? null,
|
||||
], 'أُلغيت القسيمة وعاد المبلغ إلى سقفك');
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* fuel/enroll.php — تفعيل محفظة الوقود للسائق
|
||||
*
|
||||
* يفتح التزام `fuel_wallet` بسقف ائتمان مبدئي. لا يصرف شيئاً — الصرف
|
||||
* يبدأ بقسيمة، والدَّين يُقيَّد عند تأكيد المحطة.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/../driver_assurance/eligibility.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
if (fuelObligation($con, $driverId)) {
|
||||
jsonError('محفظة الوقود مفعّلة لديك بالفعل');
|
||||
}
|
||||
|
||||
$product = obligationProduct($con, FUEL_PRODUCT_CODE);
|
||||
if (!$product) {
|
||||
error_log('[fuel/enroll] منتج محفظة الوقود غير موجود أو مُطفأ');
|
||||
jsonError('محفظة الوقود غير متاحة حالياً');
|
||||
}
|
||||
|
||||
// شروط الرحلات والتقييم تُقاس بالدالة نفسها التي يستعملها التأمين:
|
||||
// المنطق واحد (سائق أثبت أنه باقٍ)، وازدواج تطبيقه كان سينتج معيارين
|
||||
// يفترقان بصمت عند أول تعديل.
|
||||
$check = assuranceCheckEligibility($con, $driverId, $product);
|
||||
if (!$check['eligible']) {
|
||||
jsonError('لم تستوفِ شروط محفظة الوقود بعد: ' . implode('، ', $check['reasons']));
|
||||
}
|
||||
|
||||
// بوابة الائتمان أشدّ هنا منها في التأمين: الوقود قيمة فعلية تُسلَّم في
|
||||
// خزّان السيارة، ولا سبيل لاستردادها إلا من أرباح تمرّ بالمحفظة.
|
||||
$gate = obligationCheckWalletIncome($product, $driverId);
|
||||
if (!$gate['eligible']) {
|
||||
jsonError(
|
||||
'محفظة الوقود تُسدَّد من أرباحك في المحفظة، ولذلك تشترط أن تمرّ أرباحك بها: '
|
||||
. implode('، ', $gate['reasons'])
|
||||
);
|
||||
}
|
||||
|
||||
// السقف المبدئي واحد للجميع في هذه المرحلة. رفعه بحسب سلوك السداد قرار
|
||||
// لاحق يحتاج بيانات لا نملكها بعد — ومنح سقف متدرّج بلا تاريخ سداد
|
||||
// تخمينٌ يرتدي ثوب الخوارزمية.
|
||||
$defaultLimit = (float) (getenv('FUEL_DEFAULT_CREDIT_LIMIT') ?: 30);
|
||||
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
$obligationId = obligationOpen($con, $driverId, FUEL_PRODUCT_CODE, [
|
||||
'cycle_amount' => 0, // منتج سحب: لا قسط دوري له
|
||||
'rides' => $check['rides'],
|
||||
'rating' => $check['rating'],
|
||||
]);
|
||||
|
||||
$con->prepare("UPDATE driver_obligations SET credit_limit = ? WHERE id = ?")
|
||||
->execute([$defaultLimit, $obligationId]);
|
||||
|
||||
$con->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
error_log('[fuel/enroll] ' . $e->getMessage());
|
||||
jsonError('تعذّر تفعيل محفظة الوقود حالياً');
|
||||
}
|
||||
|
||||
error_log("[fuel] فُعّلت محفظة الوقود للسائق $driverId بسقف $defaultLimit");
|
||||
|
||||
jsonSuccess([
|
||||
'obligation_id' => $obligationId,
|
||||
'credit_limit' => $defaultLimit,
|
||||
'available' => $defaultLimit,
|
||||
'currency' => $product['currency'],
|
||||
], 'تم تفعيل محفظة الوقود');
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
/**
|
||||
* fuel/functions.php — منطق محفظة الوقود المشترك
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* محفظة الوقود منتج `drawdown` في محرك الالتزامات: لا دورة فوترة له،
|
||||
* والاستحقاق يُقيَّد لحظة الصرف من المحطة لا على جدول زمني.
|
||||
*
|
||||
* كل ما يخصّ المال هنا يقتصر على **قيد الدَّين**. الخصم من رصيد السائق
|
||||
* يبقى حكراً على محرك التسوية، كما في التأمين.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../obligations/functions.php';
|
||||
|
||||
const FUEL_PRODUCT_CODE = 'fuel_wallet';
|
||||
const FUEL_VOUCHER_TTL_MIN = 45;
|
||||
|
||||
/**
|
||||
* التزام الوقود النشط للسائق، أو null.
|
||||
*/
|
||||
function fuelObligation(PDO $con, string $driverId): ?array
|
||||
{
|
||||
$st = $con->prepare("
|
||||
SELECT o.*, p.currency, p.daily_cap_percent
|
||||
FROM driver_obligations o
|
||||
JOIN obligation_products p ON p.id = o.product_id
|
||||
WHERE o.driver_id = ? AND p.code = ? AND o.status = 'active'
|
||||
LIMIT 1
|
||||
");
|
||||
$st->execute([$driverId, FUEL_PRODUCT_CODE]);
|
||||
|
||||
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* يُنهي صلاحية القسائم التي مضى وقتها.
|
||||
*
|
||||
* يُستدعى قبل أي حساب سقف لا في كرون: القسيمة المنتهية تحجز من سقف
|
||||
* السائق، وتأخير تحريرها ساعةً كاملةً حتى يمرّ الكرون يعني منعه من
|
||||
* التزوّد بسبب قسيمة ماتت. التنظيف عند القراءة يجعل الرقم صادقاً دائماً.
|
||||
*/
|
||||
function fuelExpireStaleVouchers(PDO $con, string $driverId): void
|
||||
{
|
||||
$con->prepare("
|
||||
UPDATE fuel_vouchers
|
||||
SET status = 'expired'
|
||||
WHERE driver_id = ? AND status = 'issued' AND expires_at < NOW()
|
||||
")->execute([$driverId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* حالة سقف السائق: المستخدَم، المحجوز، والمتاح.
|
||||
*
|
||||
* المحجوز يُطرح كالمستخدَم تماماً: قسيمة أُصدرت ولم تُصرف بعد قد تُصرف
|
||||
* بعد لحظة، والسماح بإصدار قسائم بمجموع يتجاوز السقف يعني سائقاً
|
||||
* يتزوّد بضعف ما يستحق ثم لا نجد ما نخصم منه.
|
||||
*
|
||||
* @return array{limit: float, outstanding: float, held: float, available: float}
|
||||
*/
|
||||
function fuelCreditState(PDO $con, array $obligation): array
|
||||
{
|
||||
$obligationId = (int) $obligation['id'];
|
||||
|
||||
$st = $con->prepare("
|
||||
SELECT COALESCE(SUM(amount_remaining), 0)
|
||||
FROM obligation_ledger
|
||||
WHERE obligation_id = ? AND status IN ('pending', 'partial')
|
||||
");
|
||||
$st->execute([$obligationId]);
|
||||
$outstanding = (float) $st->fetchColumn();
|
||||
|
||||
$st = $con->prepare("
|
||||
SELECT COALESCE(SUM(amount_authorized), 0)
|
||||
FROM fuel_vouchers
|
||||
WHERE obligation_id = ? AND status = 'issued' AND expires_at >= NOW()
|
||||
");
|
||||
$st->execute([$obligationId]);
|
||||
$held = (float) $st->fetchColumn();
|
||||
|
||||
$limit = (float) $obligation['credit_limit'];
|
||||
$available = round($limit - $outstanding - $held, 3);
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'outstanding' => round($outstanding, 3),
|
||||
'held' => round($held, 3),
|
||||
'available' => max($available, 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* يولّد رمز قسيمة غير متوقَّع.
|
||||
*
|
||||
* `random_int` لا `rand`: الرمز مفتاحُ صرفٍ فعلي، ومولّد عشوائية قابل
|
||||
* للتنبّؤ يعني أن من يعرف رمزاً واحداً قد يخمّن التالي ويتزوّد على
|
||||
* حساب سائق آخر. عشرة أرقام تُبقي المجال واسعاً والرقم قابلاً للإملاء
|
||||
* على عامل المحطة هاتفياً.
|
||||
*/
|
||||
function fuelGenerateCode(PDO $con): string
|
||||
{
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$code = (string) random_int(1000000000, 9999999999);
|
||||
|
||||
$st = $con->prepare("SELECT 1 FROM fuel_vouchers WHERE code = ? LIMIT 1");
|
||||
$st->execute([$code]);
|
||||
if (!$st->fetchColumn()) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
// عشر محاولات فاشلة على مجال من تسعة مليارات ليست صدفة — هي عطل
|
||||
// (قاعدة ممتلئة أو مولّد معطوب). الاستمرار هنا يعني رمزاً مكرّراً.
|
||||
throw new RuntimeException('تعذّر توليد رمز قسيمة فريد');
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* fuel/get.php — حالة محفظة الوقود للسائق
|
||||
*
|
||||
* السقف، المستخدَم، المحجوز، والمتاح — ثم آخر الحركات. الأرقام الأربعة
|
||||
* معاً لا المتاح وحده: السائق الذي يرى «المتاح ٥» بلا تفسير يظنّها
|
||||
* عطلاً، والذي يرى «سقفك ٣٠، عليك ٢٠، محجوز ٥» يفهم ويتصرّف.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$obligation = fuelObligation($con, $driverId);
|
||||
|
||||
if (!$obligation) {
|
||||
jsonSuccess([
|
||||
'enrolled' => false,
|
||||
'credit' => null,
|
||||
'voucher' => null,
|
||||
'entries' => [],
|
||||
], 'محفظة الوقود غير مفعّلة');
|
||||
}
|
||||
|
||||
fuelExpireStaleVouchers($con, $driverId);
|
||||
$state = fuelCreditState($con, $obligation);
|
||||
|
||||
$voucher = null;
|
||||
$entries = [];
|
||||
|
||||
try {
|
||||
$st = $con->prepare("
|
||||
SELECT id, code, amount_authorized, currency, expires_at
|
||||
FROM fuel_vouchers
|
||||
WHERE driver_id = ? AND status = 'issued' AND expires_at >= NOW()
|
||||
LIMIT 1
|
||||
");
|
||||
$st->execute([$driverId]);
|
||||
if ($row = $st->fetch(PDO::FETCH_ASSOC)) {
|
||||
$voucher = [
|
||||
'voucher_id' => (int) $row['id'],
|
||||
'code' => $row['code'],
|
||||
'amount' => (float) $row['amount_authorized'],
|
||||
'expires_at' => $row['expires_at'],
|
||||
];
|
||||
}
|
||||
|
||||
// آخر عشرين حركة: كافية لشهر تزوّد، وتمنع رداً ينمو بلا حد.
|
||||
$st = $con->prepare("
|
||||
SELECT charge_date, amount, amount_collected, amount_remaining,
|
||||
currency, status, note
|
||||
FROM obligation_ledger
|
||||
WHERE obligation_id = ?
|
||||
ORDER BY id DESC LIMIT 20
|
||||
");
|
||||
$st->execute([(int) $obligation['id']]);
|
||||
$entries = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[fuel/get] تعذّرت قراءة الحالة: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
jsonSuccess([
|
||||
'enrolled' => true,
|
||||
'credit' => [
|
||||
'limit' => $state['limit'],
|
||||
'outstanding' => $state['outstanding'],
|
||||
'held' => $state['held'],
|
||||
'available' => $state['available'],
|
||||
'currency' => $obligation['currency'],
|
||||
// نسبة الاقتطاع اليومي معروضة صراحةً: الخصم من الأرباح يجب ألّا
|
||||
// يكون مفاجأة، والسائق الذي يعرف أن الاقتطاع ١٥٪ يخطّط عليه.
|
||||
'daily_cap_percent' => (float) $obligation['daily_cap_percent'],
|
||||
],
|
||||
'voucher' => $voucher,
|
||||
'entries' => $entries,
|
||||
], 'success');
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
/**
|
||||
* fuel/redeem.php — تأكيد المحطة صرف القسيمة
|
||||
* ─────────────────────────────────────────────────────────────
|
||||
* هذه هي **نقطة قيد الاستحقاق** لمنتج الوقود: اللحظة التي يتحوّل فيها
|
||||
* وقودٌ في الخزّان إلى دَين في الدفتر.
|
||||
*
|
||||
* تُنادى من نظام المحطة لا من تطبيق السائق. السائق طرف مستفيد، وجعل
|
||||
* تأكيد الصرف بيده يعني أن ينفي ما استلمه.
|
||||
*
|
||||
* لا تخصم شيئاً من رصيد أحد — تقيّد استحقاقاً، ويحصّله محرك التسوية
|
||||
* بالسقف اليومي. نفس الفصل الذي يحكم التأمين.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
} catch (Exception $e) {
|
||||
error_log('[fuel/redeem] DB unavailable: ' . $e->getMessage());
|
||||
jsonError('Server error', 500);
|
||||
}
|
||||
|
||||
// مصادقة المحطة بمفتاحها الخاص لا بمفتاح النظام المشترك: المفاتيح
|
||||
// موزّعة على أطراف خارجية، وتسريب مفتاح محطة يجب أن يكشف تلك المحطة
|
||||
// وحدها ويُبطَل بصفّ واحد.
|
||||
$stationKey = $_SERVER['HTTP_X_STATION_KEY'] ?? '';
|
||||
if (empty($stationKey)) {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$st = $con->prepare("
|
||||
SELECT id, name_ar, discount_percent
|
||||
FROM fuel_stations
|
||||
WHERE api_key_hash = ? AND is_active = 1 LIMIT 1
|
||||
");
|
||||
$st->execute([hash('sha256', $stationKey)]);
|
||||
$station = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$station) {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$code = filterRequest('code');
|
||||
$amount = (float) (filterRequest('amount', 'numeric') ?: 0);
|
||||
|
||||
if (empty($code) || $amount <= 0) {
|
||||
jsonError('code و amount مطلوبان');
|
||||
}
|
||||
|
||||
try {
|
||||
$con->beginTransaction();
|
||||
|
||||
// القفل ضروري لا احتياطي: طلبان متزامنان بالرمز نفسه (ضغطة مكرّرة
|
||||
// على جهاز المحطة، أو محاولة صرف مزدوج) كانا سيمرّان كلاهما فيُقيَّد
|
||||
// الدَّين مرتين على وقود صُرف مرة.
|
||||
$st = $con->prepare("
|
||||
SELECT v.*, o.driver_id AS o_driver
|
||||
FROM fuel_vouchers v
|
||||
JOIN driver_obligations o ON o.id = v.obligation_id
|
||||
WHERE v.code = ? FOR UPDATE
|
||||
");
|
||||
$st->execute([$code]);
|
||||
$voucher = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$voucher) {
|
||||
$con->rollBack();
|
||||
jsonError('رمز غير معروف');
|
||||
}
|
||||
|
||||
if ($voucher['status'] !== 'issued') {
|
||||
$con->rollBack();
|
||||
// رسالة صريحة لا عامة: عامل المحطة يحتاج أن يعرف إن كانت صُرفت
|
||||
// سابقاً أم انتهت صلاحيتها، وإلا أعاد المحاولة أمام سائق ينتظر.
|
||||
jsonError('القسيمة ' . match ($voucher['status']) {
|
||||
'redeemed' => 'مصروفة سابقاً',
|
||||
'expired' => 'منتهية الصلاحية',
|
||||
'cancelled' => 'ملغاة',
|
||||
default => 'غير قابلة للصرف',
|
||||
});
|
||||
}
|
||||
|
||||
if (strtotime($voucher['expires_at']) < time()) {
|
||||
$con->prepare("UPDATE fuel_vouchers SET status = 'expired' WHERE id = ?")
|
||||
->execute([(int) $voucher['id']]);
|
||||
$con->commit();
|
||||
jsonError('القسيمة منتهية الصلاحية');
|
||||
}
|
||||
|
||||
// الصرف لا يتجاوز المأذون. تجاوزه يعني ديناً لم يوافق عليه السائق
|
||||
// ولم يُحجز له سقف — أي خرقاً للسقف من باب خلفي.
|
||||
if ($amount > (float) $voucher['amount_authorized']) {
|
||||
$con->rollBack();
|
||||
jsonError(
|
||||
'المبلغ يتجاوز المأذون به في القسيمة ('
|
||||
. (float) $voucher['amount_authorized'] . ')'
|
||||
);
|
||||
}
|
||||
|
||||
$con->prepare("
|
||||
UPDATE fuel_vouchers
|
||||
SET status = 'redeemed', station_id = ?, amount_redeemed = ?,
|
||||
discount_percent = ?, redeemed_at = NOW()
|
||||
WHERE id = ?
|
||||
")->execute([
|
||||
(int) $station['id'], $amount,
|
||||
(float) $station['discount_percent'], (int) $voucher['id'],
|
||||
]);
|
||||
|
||||
// قيد الاستحقاق. `source_ref` رقم القسيمة — وهو ما يسمح بأكثر من
|
||||
// سحبة في اليوم الواحد (المفتاح الفريد يشمله)، ويجعل إعادة النداء
|
||||
// بالرمز نفسه غير قادرة على مضاعفة الدَّين.
|
||||
$con->prepare("
|
||||
INSERT INTO obligation_ledger
|
||||
(obligation_id, driver_id, product_code, charge_date, source_ref,
|
||||
amount, amount_collected, amount_remaining, currency, status, note)
|
||||
VALUES (?, ?, ?, CURDATE(), ?, ?, 0, ?, ?, 'pending', ?)
|
||||
")->execute([
|
||||
(int) $voucher['obligation_id'], $voucher['driver_id'], FUEL_PRODUCT_CODE,
|
||||
'voucher_' . (int) $voucher['id'],
|
||||
$amount, $amount, $voucher['currency'],
|
||||
mb_substr('تزوّد من ' . $station['name_ar'], 0, 255),
|
||||
]);
|
||||
|
||||
$con->commit();
|
||||
} catch (PDOException $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
|
||||
// 23000 على المفتاح الفريد يعني أن هذه القسيمة قُيّدت فعلاً — نداء
|
||||
// مكرّر لا خطأ. القسيمة مصروفة والدَّين مقيَّد مرة واحدة.
|
||||
if ($e->getCode() === '23000') {
|
||||
jsonSuccess(['code' => $code, 'duplicate' => true], 'القسيمة مصروفة سابقاً');
|
||||
}
|
||||
|
||||
error_log('[fuel/redeem] ' . $e->getMessage());
|
||||
jsonError('تعذّر إتمام الصرف', 500);
|
||||
} catch (Throwable $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
error_log('[fuel/redeem] ' . $e->getMessage());
|
||||
jsonError('تعذّر إتمام الصرف', 500);
|
||||
}
|
||||
|
||||
error_log("[fuel] صُرفت القسيمة {$code} في محطة #{$station['id']} بقيمة $amount");
|
||||
|
||||
jsonSuccess([
|
||||
'code' => $code,
|
||||
'amount' => $amount,
|
||||
'currency' => $voucher['currency'],
|
||||
'station' => $station['name_ar'],
|
||||
'discount_percent' => (float) $station['discount_percent'],
|
||||
], 'تم الصرف');
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
/**
|
||||
* fuel/request_voucher.php — إصدار قسيمة تزوّد
|
||||
*
|
||||
* السائق يطلب مبلغاً فيحصل على رمز يعطيه لعامل المحطة. لا دَين بعد —
|
||||
* القسيمة حجزٌ من السقف فقط، والدَّين يُقيَّد عند تأكيد المحطة الصرف.
|
||||
*
|
||||
* لماذا قسيمة مسبقة لا خصم عند المضخة؟ لأن عامل المحطة لا يملك وسيلة
|
||||
* للتحقق من هوية السائق ولا من سقفه. القسيمة تنقل القرار إلى تطبيق
|
||||
* السائق حيث الهوية مؤكَّدة والسقف معلوم، ولا يبقى على المحطة إلا
|
||||
* تأكيد رقم.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$amount = (float) (filterRequest('amount', 'numeric') ?: 0);
|
||||
if ($amount <= 0) {
|
||||
jsonError('حدّد مبلغ التزوّد');
|
||||
}
|
||||
|
||||
$obligation = fuelObligation($con, $driverId);
|
||||
if (!$obligation) {
|
||||
jsonError('فعّل محفظة الوقود أولاً');
|
||||
}
|
||||
|
||||
// تحرير المنتهي قبل الحساب: بدونه يبقى المبلغ محجوزاً بقسيمة ماتت،
|
||||
// فيُمنع السائق من التزوّد بسبب طلب نسيه قبل ساعة.
|
||||
fuelExpireStaleVouchers($con, $driverId);
|
||||
|
||||
// قسيمة مفتوحة واحدة في كل وقت. قسيمتان تعنيان رمزين في يد السائق،
|
||||
// وخطأ عامل المحطة في اختيار أحدهما يصرف المبلغ الخطأ — والأهم أن
|
||||
// السائق لا يحتاج أكثر من واحدة أصلاً.
|
||||
$st = $con->prepare("
|
||||
SELECT id, code, amount_authorized, expires_at
|
||||
FROM fuel_vouchers
|
||||
WHERE driver_id = ? AND status = 'issued' AND expires_at >= NOW()
|
||||
LIMIT 1
|
||||
");
|
||||
$st->execute([$driverId]);
|
||||
if ($open = $st->fetch(PDO::FETCH_ASSOC)) {
|
||||
jsonError(
|
||||
'لديك قسيمة سارية بالرمز ' . $open['code'] . ' بقيمة '
|
||||
. (float) $open['amount_authorized'] . '. استخدمها أو ألغِها أولاً.'
|
||||
);
|
||||
}
|
||||
|
||||
$state = fuelCreditState($con, $obligation);
|
||||
if ($amount > $state['available']) {
|
||||
jsonError(
|
||||
'المتاح في محفظة وقودك ' . $state['available'] . ' ' . $obligation['currency']
|
||||
. '. سدّد المستحق عليك ليعود سقفك.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$code = fuelGenerateCode($con);
|
||||
|
||||
$con->prepare("
|
||||
INSERT INTO fuel_vouchers
|
||||
(obligation_id, driver_id, code, amount_authorized, currency, status, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'issued', DATE_ADD(NOW(), INTERVAL ? MINUTE))
|
||||
")->execute([
|
||||
(int) $obligation['id'], $driverId, $code, $amount,
|
||||
$obligation['currency'], FUEL_VOUCHER_TTL_MIN,
|
||||
]);
|
||||
|
||||
$voucherId = (int) $con->lastInsertId();
|
||||
} catch (Throwable $e) {
|
||||
error_log('[fuel/request_voucher] ' . $e->getMessage());
|
||||
jsonError('تعذّر إصدار القسيمة حالياً');
|
||||
}
|
||||
|
||||
error_log("[fuel] قسيمة #$voucherId للسائق $driverId بقيمة $amount");
|
||||
|
||||
jsonSuccess([
|
||||
'voucher_id' => $voucherId,
|
||||
'code' => $code,
|
||||
'amount' => $amount,
|
||||
'currency' => $obligation['currency'],
|
||||
'expires_in_min' => FUEL_VOUCHER_TTL_MIN,
|
||||
'available_after' => round($state['available'] - $amount, 3),
|
||||
], 'أعطِ هذا الرمز لعامل المحطة');
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* fuel/stations.php — المحطات الشريكة
|
||||
*
|
||||
* قائمة للسائق، مرتّبة بالقرب إن أرسل موقعه. لا يُرجع مفاتيح المحطات
|
||||
* ولا أي حقل تشغيلي — القائمة تُعرض في تطبيق السائق، وكل ما يخرج إليه
|
||||
* يجب افتراض أنه علنيّ.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../connect.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$driverId = $user_id ?? '';
|
||||
if (empty($driverId) || ($role ?? '') !== 'driver') {
|
||||
jsonError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
$lat = filterRequest('lat', 'numeric');
|
||||
$lng = filterRequest('lng', 'numeric');
|
||||
|
||||
try {
|
||||
if ($lat !== null && $lng !== null) {
|
||||
// مسافة تقريبية بفرق الإحداثيات لا Haversine: الغرض ترتيب قائمة
|
||||
// محطات في مدينة واحدة، والفارق بين الطريقتين على هذا النطاق
|
||||
// لا يغيّر ترتيباً — بينما الحساب المثلثي على كل صف يكلّف بلا مقابل.
|
||||
$st = $con->prepare("
|
||||
SELECT id, name_ar, chain_name, city, address, lat, lng, phone,
|
||||
discount_percent,
|
||||
(POW(lat - ?, 2) + POW(lng - ?, 2)) AS dist
|
||||
FROM fuel_stations
|
||||
WHERE is_active = 1
|
||||
ORDER BY (lat IS NULL), dist ASC
|
||||
LIMIT 50
|
||||
");
|
||||
$st->execute([(float) $lat, (float) $lng]);
|
||||
} else {
|
||||
$st = $con->query("
|
||||
SELECT id, name_ar, chain_name, city, address, lat, lng, phone,
|
||||
discount_percent
|
||||
FROM fuel_stations
|
||||
WHERE is_active = 1
|
||||
ORDER BY city ASC, name_ar ASC
|
||||
LIMIT 50
|
||||
");
|
||||
}
|
||||
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (PDOException $e) {
|
||||
error_log('[fuel/stations] ' . $e->getMessage());
|
||||
jsonError('Server error');
|
||||
}
|
||||
|
||||
$out = array_map(static fn(array $r): array => [
|
||||
'id' => (int) $r['id'],
|
||||
'name' => $r['name_ar'],
|
||||
'chain' => $r['chain_name'],
|
||||
'city' => $r['city'],
|
||||
'address' => $r['address'],
|
||||
'lat' => $r['lat'] !== null ? (float) $r['lat'] : null,
|
||||
'lng' => $r['lng'] !== null ? (float) $r['lng'] : null,
|
||||
'phone' => $r['phone'],
|
||||
'discount_percent' => (float) $r['discount_percent'],
|
||||
], $rows);
|
||||
|
||||
jsonSuccess(['stations' => $out], 'success');
|
||||
+33
-2
@@ -365,7 +365,12 @@ function notifyPassengerOnRideServer($passenger_id, $payload) {
|
||||
// ============================================================================
|
||||
// دالة توزيع الطلب (Dispatch Function) - النسخة المصححة
|
||||
// ============================================================================
|
||||
function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNameLoc, $encryptionHelper, $extraFcmData = []) {
|
||||
/**
|
||||
* $offerInfo — وسم العرض في الإسناد بالدفعات: ['exclusive', 5].
|
||||
* يُمرَّر فقط لصاحب السبق الحصري، ويُترك null في البثّ الحر فلا يتغيّر شيء.
|
||||
* الفهرسان 37 و38 يقرأهما OrderData.fromList في تطبيق السائق.
|
||||
*/
|
||||
function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNameLoc, $encryptionHelper, $extraFcmData = [], $offerInfo = null) {
|
||||
$countDrivers = count($driversData);
|
||||
error_log("🚀 [DISPATCH_START] RideID: $rideId | Drivers Count: $countDrivers");
|
||||
|
||||
@@ -386,7 +391,28 @@ function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNa
|
||||
// تجهيز البيانات الخاصة بالسائق
|
||||
$payloadForDriver = $payloadTemplate;
|
||||
$payloadForDriver[6] = (string)$driverId;
|
||||
$payloadForDriver[18] = (string)$driverId;
|
||||
$payloadForDriver[18] = (string)$driverId;
|
||||
|
||||
// بيانات الراكب (التوكن 9، الهاتف 10، البريد 24) تبقى في الحمولة
|
||||
// بقرار المالك: التطبيق لا يعرضها في صفحة الطلب، ولا تظهر إلا بعد
|
||||
// القبول في صفحة خريطة الراكب. إرسالها مسبقاً يوفّر استعلاماً على
|
||||
// القاعدة عند كل قبول.
|
||||
//
|
||||
// المقايضة المعروفة: الحجب واجهةٌ لا خادم — الحمولة نفسها تصل كل
|
||||
// المرشحين. لذلك يقوم إغلاق التسرب هنا على الكشف والعقوبة (أدناه)
|
||||
// لا على حجب البيانات.
|
||||
|
||||
// وسم العرض للفهرسَين اللذين يقرأهما تطبيق السائق. الفهارس بينهما
|
||||
// قد تكون غير معرَّفة، فنملؤها بفراغ وإلا صارت القائمة متفرقة
|
||||
// (sparse) وخرجت من json_encode كخريطة لا كمصفوفة.
|
||||
if ($offerInfo !== null) {
|
||||
for ($i = count($payloadForDriver); $i < 37; $i++) {
|
||||
$payloadForDriver[$i] = '';
|
||||
}
|
||||
$payloadForDriver[37] = (string)$offerInfo[0];
|
||||
$payloadForDriver[38] = (string)$offerInfo[1];
|
||||
ksort($payloadForDriver);
|
||||
}
|
||||
|
||||
// 2. إرسال السوكيت
|
||||
sendSocketNotification($driverId, $rideId, $payloadForDriver, $socketUrl, $internalKey);
|
||||
@@ -400,6 +426,11 @@ function dispatchRideToDrivers($driversData, $rideId, $payloadTemplate, $startNa
|
||||
|
||||
// 🆕 Destination Match Indicator
|
||||
$title = "طلب جديد 🔔";
|
||||
if ($offerInfo !== null && $offerInfo[0] === 'exclusive') {
|
||||
$title = "⭐ طلب خاص لك";
|
||||
$fcmData['offer_type'] = 'exclusive';
|
||||
$fcmData['offer_expires_in'] = (string)$offerInfo[1];
|
||||
}
|
||||
if (!empty($driver['is_destination_match'])) {
|
||||
$title = "في طريقك! 📍";
|
||||
$fcmData['is_destination_match'] = "1";
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?php
|
||||
// نفس الاسم معرّف أيضاً في core/helpers.php بتوقيع مختلف. أي ملف يُحمِّل
|
||||
// bootstrap.php (ومنه helpers.php) ثم يصل إلى هنا كان ينهار بـ
|
||||
// "Cannot redeclare loadEnvironment()" — وهو ما أسقط blacklist_manager.php.
|
||||
// الحارس يُبقي أول تعريف محمَّل ويتجاهل الثاني، والسلوك متوافق بينهما.
|
||||
if (!function_exists('loadEnvironment')) {
|
||||
function loadEnvironment($env_file) {
|
||||
if (!file_exists($env_file)) {
|
||||
error_log("❌ .env not found: $env_file");
|
||||
@@ -19,4 +24,5 @@ function loadEnvironment($env_file) {
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+76
-29
@@ -53,47 +53,94 @@ try {
|
||||
$stmt->execute([':id' => $id]);
|
||||
$driver = $stmt->fetch();
|
||||
|
||||
if (!$driver || empty($driver['password'])) {
|
||||
if (!$driver) {
|
||||
unauthorizedDriver();
|
||||
}
|
||||
|
||||
$decPhone = !empty($driver['phone']) ? $encryptionHelper->decryptData($driver['phone']) : null;
|
||||
$decNat = !empty($driver['national_number']) ? $encryptionHelper->decryptData($driver['national_number']) : null;
|
||||
// ── مسار السائقين المسجلين عبر OTP (بدون password في DB) ──
|
||||
if (empty($driver['password'])) {
|
||||
// التحقق من هوية السائق عبر fingerprint المخزن في driverToken
|
||||
if (empty($fingerprint)) {
|
||||
securityLog("LoginDriver(OTP): no fingerprint sent", [
|
||||
'driver_id' => $driver['id'],
|
||||
]);
|
||||
unauthorizedDriver();
|
||||
}
|
||||
|
||||
if (empty($decPhone)) {
|
||||
securityLog("LoginDriver failed: phone decryption returned null", [
|
||||
'driver_id' => $driver['id'] ?? 'unknown',
|
||||
]);
|
||||
unauthorizedDriver();
|
||||
}
|
||||
$stmtFp = $con->prepare('SELECT fingerPrint FROM driverToken WHERE captain_id = :id LIMIT 1');
|
||||
$stmtFp->execute([':id' => $driver['id']]);
|
||||
$fpRow = $stmtFp->fetch();
|
||||
|
||||
// ── المحاولة الأولى: طريقة جديدة (قيم خام) ─────────────
|
||||
$newParts = [
|
||||
$driver['id'],
|
||||
trim($decPhone),
|
||||
];
|
||||
if (!empty($decNat)) {
|
||||
$newParts[] = trim($decNat);
|
||||
}
|
||||
$newString = implode('|', $newParts);
|
||||
$newSecret = hash_hmac('sha256', $newString, $pepper, true);
|
||||
if (!$fpRow || empty($fpRow['fingerPrint'])) {
|
||||
securityLog("LoginDriver(OTP): no stored fingerprint found", [
|
||||
'driver_id' => $driver['id'],
|
||||
]);
|
||||
unauthorizedDriver();
|
||||
}
|
||||
|
||||
if (password_verify($newSecret, $driver['password'])) {
|
||||
// ✅ صح - طريقة جديدة
|
||||
// driverToken.fingerPrint مكتوب بصيغتين حسب آخر endpoint لمسه:
|
||||
// • خام — من verify.php و ride/firebase/addDriver.php
|
||||
// • hash مملّح — من loginJwtWalletDriver.php ومن هنا
|
||||
// القبول على صيغة واحدة فقط كان يرفض كل سائق OTP لم يفتح المحفظة
|
||||
// بعد (401 دائم ⇒ يستحيل ترقية توكن التسجيل). نقبل الاثنتين ثم
|
||||
// نُوحّد الصف على الصيغة المُهشّمة.
|
||||
$fpPepper = getenv('FP_PEPPER') ?: '';
|
||||
$storedFp = $fpRow['fingerPrint'];
|
||||
$hashedFp = !empty($fpPepper) ? hash('sha256', $fingerprint . $fpPepper) : $fingerprint;
|
||||
|
||||
if (!hash_equals($storedFp, $hashedFp) && !hash_equals($storedFp, $fingerprint)) {
|
||||
securityLog("LoginDriver(OTP): fingerprint mismatch", [
|
||||
'driver_id' => $driver['id'],
|
||||
]);
|
||||
unauthorizedDriver();
|
||||
}
|
||||
|
||||
// توحيد التخزين على الصيغة المُهشّمة (لا نحفظ البصمة الخام).
|
||||
if (!hash_equals($storedFp, $hashedFp)) {
|
||||
$con->prepare('UPDATE driverToken SET fingerPrint = :fp WHERE captain_id = :cid')
|
||||
->execute([':fp' => $hashedFp, ':cid' => $driver['id']]);
|
||||
}
|
||||
// ✅ تم التحقق عبر fingerprint — نتابع لتوليد JWT
|
||||
} else {
|
||||
// ── المحاولة الثانية: طريقة قديمة (قيم مشفرة) للتوافق ─
|
||||
$oldParts = [
|
||||
// ── المسار التقليدي: التحقق عبر password + HMAC ──────────
|
||||
$decPhone = !empty($driver['phone']) ? $encryptionHelper->decryptData($driver['phone']) : null;
|
||||
$decNat = !empty($driver['national_number']) ? $encryptionHelper->decryptData($driver['national_number']) : null;
|
||||
|
||||
if (empty($decPhone)) {
|
||||
securityLog("LoginDriver failed: phone decryption returned null", [
|
||||
'driver_id' => $driver['id'] ?? 'unknown',
|
||||
]);
|
||||
unauthorizedDriver();
|
||||
}
|
||||
|
||||
// ── المحاولة الأولى: طريقة جديدة (قيم خام) ─────────────
|
||||
$newParts = [
|
||||
$driver['id'],
|
||||
$encryptionHelper->encryptData(trim($decPhone)),
|
||||
trim($decPhone),
|
||||
];
|
||||
if (!empty($decNat)) {
|
||||
$oldParts[] = $encryptionHelper->encryptData(trim($decNat));
|
||||
$newParts[] = trim($decNat);
|
||||
}
|
||||
$oldString = implode('|', $oldParts);
|
||||
$oldSecret = hash_hmac('sha256', $oldString, $pepper, true);
|
||||
$newString = implode('|', $newParts);
|
||||
$newSecret = hash_hmac('sha256', $newString, $pepper, true);
|
||||
|
||||
if (!password_verify($oldSecret, $driver['password'])) {
|
||||
unauthorizedDriver();
|
||||
if (password_verify($newSecret, $driver['password'])) {
|
||||
// ✅ صح - طريقة جديدة
|
||||
} else {
|
||||
// ── المحاولة الثانية: طريقة قديمة (قيم مشفرة) للتوافق ─
|
||||
$oldParts = [
|
||||
$driver['id'],
|
||||
$encryptionHelper->encryptData(trim($decPhone)),
|
||||
];
|
||||
if (!empty($decNat)) {
|
||||
$oldParts[] = $encryptionHelper->encryptData(trim($decNat));
|
||||
}
|
||||
$oldString = implode('|', $oldParts);
|
||||
$oldSecret = hash_hmac('sha256', $oldString, $pepper, true);
|
||||
|
||||
if (!password_verify($oldSecret, $driver['password'])) {
|
||||
unauthorizedDriver();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,18 @@ require_once __DIR__ . '/../core/Services/FcmService.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Simple bot authentication
|
||||
// مصادقة البوت.
|
||||
// كان هذا الفحص معطّلاً بالتعليق، فكانت النقطة مفتوحة على الإنترنت لأي أحد:
|
||||
// سحب مهام التسويق، وتعليمها منجزة، والكتابة في قاعدة البيانات.
|
||||
// القيمة الافتراضية هي نفسها المبنية حالياً في APK (BuildConfig.BOT_TOKEN)
|
||||
// حتى لا ينكسر البوت الميداني؛ غيّرها في الطرفين معاً عبر SOCIAL_BOT_TOKEN.
|
||||
$headers = getallheaders();
|
||||
$botToken = $headers['X-Bot-Token'] ?? '';
|
||||
// if ($botToken !== 'YOUR_SECRET_BOT_TOKEN') {
|
||||
// http_response_code(401);
|
||||
// exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
||||
// }
|
||||
$expectedToken = getenv('SOCIAL_BOT_TOKEN') ?: 'YOUR_SECRET_BOT_TOKEN';
|
||||
if (!hash_equals($expectedToken, $botToken)) {
|
||||
http_response_code(401);
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
$platform = $_GET['platform'] ?? 'facebook';
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
// Authentication and validation could be added here similar to driver_socket.php
|
||||
// For now, let's keep it simple or use a static token for the bot
|
||||
// مصادقة البوت.
|
||||
// كان جسم الشرط معطّلاً بالتعليق، فكان الفحص بلا أثر والنقطة مفتوحة على
|
||||
// الإنترنت: أي أحد يستطيع سحب المهام والكتابة في social_logs وقراءة الحسابات.
|
||||
// القيمة الافتراضية هي نفسها المبنية حالياً في APK (BuildConfig.BOT_TOKEN)
|
||||
// حتى لا ينكسر البوت الميداني؛ غيّرها في الطرفين معاً عبر SOCIAL_BOT_TOKEN.
|
||||
$headers = getallheaders();
|
||||
$botToken = $headers['X-Bot-Token'] ?? '';
|
||||
if ($botToken !== 'YOUR_SECRET_BOT_TOKEN') {
|
||||
// In production, use a secure token or JWT
|
||||
// http_response_code(401);
|
||||
// exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
||||
$expectedToken = getenv('SOCIAL_BOT_TOKEN') ?: 'YOUR_SECRET_BOT_TOKEN';
|
||||
if (!hash_equals($expectedToken, $botToken)) {
|
||||
http_response_code(401);
|
||||
exit(json_encode(['status' => 'error', 'message' => 'Unauthorized']));
|
||||
}
|
||||
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- ============================================================
|
||||
-- scraped_competitor_prices: add distance_km / duration_min
|
||||
-- ============================================================
|
||||
-- backend/bot/worker.php computes both values via OSRM
|
||||
-- (core/osrm_routing.php) and has always listed them in its
|
||||
-- INSERT, but the columns were never created — so every
|
||||
-- production bot submission failed with a PDOException.
|
||||
--
|
||||
-- Consumers currently derive distance as
|
||||
-- (price_amount / price_per_km)
|
||||
-- e.g. Admin/marketing/what_if_simulator.php and
|
||||
-- Admin/marketing/trigger_campaign.php
|
||||
-- Those keep working unchanged; the new columns are additive
|
||||
-- and nullable, so historical rows stay valid.
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE `scraped_competitor_prices`
|
||||
ADD COLUMN `distance_km` DECIMAL(8,3) DEFAULT NULL AFTER `price_per_km`,
|
||||
ADD COLUMN `duration_min` DECIMAL(8,2) DEFAULT NULL AFTER `distance_km`;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- ============================================================
|
||||
-- admin_audit_log — جدول سجل تدقيق عمليات الأدمن
|
||||
-- ============================================================
|
||||
-- الجدول كان مستخدماً في الكود ولا وجود له في schema_primary.sql، أي أنه
|
||||
-- غير موجود على أي نشر نظيف. والنتيجة:
|
||||
--
|
||||
-- • logAudit() في backend/functions.php يبتلع الاستثناء ويكتفي بـ
|
||||
-- error_log، فكل عملية تدقيق كانت تفشل بصمت (تحديث كازان، تعديل سائق،
|
||||
-- إرسال إشعار جماعي، تشغيل حملة، تحديث إصدار التطبيق).
|
||||
-- • شاشة "سجلات الأمان" تظهر فارغة دائماً — لا لأن العرض معطّل بل لأن
|
||||
-- الاستعلام لا يجد جدولاً.
|
||||
--
|
||||
-- الأعمدة هنا هي اتحاد ما يكتبه المصدران المختلفان:
|
||||
-- - functions.php::logAudit → admin_id, action, table_name, record_id, details
|
||||
-- - Admin/v2/quality/blacklist_manager.php → admin_id, admin_phone, action, table_name, entity_type, details
|
||||
-- ولذلك فالأعمدة غير المشتركة تقبل NULL.
|
||||
--
|
||||
-- ملاحظة الترميز: audit_logs.php يربط admin_id مع employee.id و adminUser.id
|
||||
-- عبر COLLATE utf8mb4_general_ci صراحةً، فنُثبّت الترميز نفسه هنا لتفادي
|
||||
-- خطأ "Illegal mix of collations" عند الربط.
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `admin_audit_log` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`admin_id` VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||
`admin_phone` VARCHAR(32) DEFAULT NULL,
|
||||
`action` VARCHAR(255) NOT NULL,
|
||||
`table_name` VARCHAR(100) DEFAULT NULL,
|
||||
`entity_type` VARCHAR(100) DEFAULT NULL,
|
||||
`record_id` VARCHAR(100) DEFAULT NULL,
|
||||
`details` TEXT DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY `idx_admin_id` (`admin_id`),
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_action` (`action`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- ============================================================
|
||||
-- marketing_tasks + marketing_reports — جدولا محرّك التسويق والبوت الاجتماعي
|
||||
-- ============================================================
|
||||
-- كلا الجدولين مستعمل في الكود ولا وجود لهما في schema_primary.sql — نفس
|
||||
-- حالة admin_audit_log بالضبط. الأثر:
|
||||
--
|
||||
-- • marketing_tasks هو طابور مهام البوت الاجتماعي:
|
||||
-- يكتب فيه cron_insert_task.php (كل 4 ساعات) و insert_autonomous_task.php
|
||||
-- ويقرأ منه marketing_engine/index.php ليخدم socialBot.
|
||||
-- بغيابه لا يستلم البوت مهمة واحدة أبداً، ويفشل الكرون بصمت.
|
||||
--
|
||||
-- • marketing_reports هو مصدر شاشة "الذكاء الاجتماعي":
|
||||
-- يكتب فيه telegram_scraper.php (كل 3 ساعات) و cron_weekly_report.php
|
||||
-- و index.php (evaluate_posts).
|
||||
-- بغيابه تظهر الشاشة فارغة دائماً — وهو ما لوحظ.
|
||||
--
|
||||
-- الأعمدة مستخرجة من مواضع الاستعمال الفعلية في:
|
||||
-- marketing_engine/index.php, cron_insert_task.php, cron_weekly_report.php,
|
||||
-- insert_autonomous_task.php, telegram_scraper.php, insert_test_task.php
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `marketing_tasks` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`platform` VARCHAR(50) NOT NULL DEFAULT 'facebook',
|
||||
`type` VARCHAR(100) NOT NULL,
|
||||
-- 'pending' هي الحالة التي يبحث عنها get_task؛ أي قيمة أخرى تعني
|
||||
-- أن المهمة قيد التنفيذ أو انتهت.
|
||||
`status` VARCHAR(30) NOT NULL DEFAULT 'pending',
|
||||
`target_url` TEXT DEFAULT NULL,
|
||||
`content_text` TEXT DEFAULT NULL,
|
||||
`media_url` TEXT DEFAULT NULL,
|
||||
`error_message` TEXT DEFAULT NULL,
|
||||
`completed_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- get_task يرشّح بـ status + platform ويرتّب بـ id
|
||||
KEY `idx_status_platform` (`status`, `platform`),
|
||||
KEY `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `marketing_reports` (
|
||||
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
`platform` VARCHAR(50) NOT NULL DEFAULT 'all',
|
||||
`report_html` LONGTEXT NOT NULL,
|
||||
-- cron_weekly_report.php يكتب 1؛ التقارير اللحظية تترك 0
|
||||
`is_weekly` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- get_reports يرتّب بـ created_at تنازلياً
|
||||
KEY `idx_created_at` (`created_at`),
|
||||
KEY `idx_is_weekly` (`is_weekly`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- 2026_08_07_complaint_resolution.sql
|
||||
--
|
||||
-- دورة الشكوى وأداة التعويض.
|
||||
--
|
||||
-- الوضع اليوم: serviceapp/update_complaint.php يقلب الحالة ويكتب نصّ
|
||||
-- قرار. لا تعويض، لا تصنيف سبب، ولا قياس زمن. والموظف الذي يريد
|
||||
-- تعويض راكب متضرر لا يملك أي أداة — فيَعِد شفهياً أو لا يفعل شيئاً.
|
||||
--
|
||||
-- كلمة "refund" لا ترد إطلاقاً في مسار الرحلات، فقط في وحدة الطعام.
|
||||
--
|
||||
-- هذا الترحيل يحوّل الدعم من "استقبال شكاوى" إلى "إغلاق قضايا".
|
||||
--
|
||||
-- ⚠️ يُنفَّذ على قاعدة primary (jorSiroDB) وحدها.
|
||||
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
-- ١) قياس الزمن (SLA)
|
||||
--
|
||||
-- date_resolved موجود أصلاً، لكن زمن **أول رد** هو المؤشر الذي يشعر به
|
||||
-- العميل فعلاً — شكوى تُغلق بعد يومين بردٍّ خلال دقائق أفضل بكثير من
|
||||
-- شكوى تُغلق بعد ساعة بلا أن يعلم صاحبها شيئاً.
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
ALTER TABLE `complaint`
|
||||
ADD COLUMN `first_response_at` DATETIME NULL DEFAULT NULL
|
||||
COMMENT 'لحظة أول رد من الدعم — يُختم مرة واحدة ولا يتغيّر',
|
||||
ADD COLUMN `assigned_to` VARCHAR(100) NULL DEFAULT NULL
|
||||
COMMENT 'معرّف الموظف المسؤول عن القضية',
|
||||
ADD COLUMN `reason_code` VARCHAR(40) NULL DEFAULT NULL
|
||||
COMMENT 'تصنيف موحّد للسبب — قائمة مغلقة في resolve_complaint.php',
|
||||
ADD INDEX `idx_status_filed` (`statusComplaint`, `date_filed`),
|
||||
ADD INDEX `idx_ride` (`ride_id`);
|
||||
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
-- ٢) سجل التعويضات
|
||||
--
|
||||
-- جدول مستقل لا أعمدة في complaint: الشكوى الواحدة قد تُعوَّض أكثر من
|
||||
-- مرة (استرجاع جزئي ثم رصيد اعتذار)، وكل حركة مالية تحتاج أثراً مستقلاً
|
||||
-- يُراجَع ويُدقَّق.
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS `complaint_compensations` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`complaint_id` INT NOT NULL,
|
||||
`ride_id` VARCHAR(20) NULL DEFAULT NULL,
|
||||
|
||||
`beneficiary_type` VARCHAR(20) NOT NULL
|
||||
COMMENT 'passenger | driver — لمن ذهب التعويض',
|
||||
`beneficiary_id` VARCHAR(100) NOT NULL,
|
||||
|
||||
`kind` VARCHAR(20) NOT NULL
|
||||
COMMENT 'refund (استرجاع من قيمة الرحلة) | goodwill (رصيد اعتذار)',
|
||||
`amount` DECIMAL(12,3) NOT NULL,
|
||||
`currency` VARCHAR(5) NOT NULL DEFAULT 'SYP',
|
||||
|
||||
`issued_by` VARCHAR(100) NOT NULL COMMENT 'الموظف الذي صرفه',
|
||||
`note` VARCHAR(500) NULL DEFAULT NULL,
|
||||
|
||||
-- نتيجة نداء المحفظة. الفشل يبقى مسجَّلاً للتسوية اليدوية بدل أن
|
||||
-- يختفي في اللوجز — العميل وُعد بمبلغ ويجب أن يصله.
|
||||
`transfer_status` VARCHAR(20) NOT NULL DEFAULT 'pending'
|
||||
COMMENT 'pending | success | failed',
|
||||
`transfer_code` SMALLINT NULL DEFAULT NULL,
|
||||
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_complaint` (`complaint_id`),
|
||||
KEY `idx_beneficiary` (`beneficiary_type`, `beneficiary_id`),
|
||||
KEY `idx_failed` (`transfer_status`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
COMMENT='تعويضات الشكاوى — كل حركة مالية بأثر مستقل قابل للتدقيق';
|
||||
|
||||
-- للتراجع:
|
||||
-- ALTER TABLE `complaint` DROP COLUMN `first_response_at`,
|
||||
-- DROP COLUMN `assigned_to`, DROP COLUMN `reason_code`,
|
||||
-- DROP INDEX `idx_status_filed`, DROP INDEX `idx_ride`;
|
||||
-- DROP TABLE `complaint_compensations`;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- 2026_08_07_driver_cancel_compensation.sql
|
||||
--
|
||||
-- تعويض السائق عند عدم حضور الراكب (إلغاء السائق بعد الوصول والانتظار).
|
||||
-- النموذج: التعويض = مسافة الاقتراب إلى الراكب × سعر الكيلومتر لنوع السيارة
|
||||
-- + دقائق الانتظار الفعلية × سعر الدقيقة حسب وقت اليوم.
|
||||
-- يُخصم من محفظة الراكب ويُضاف لمحفظة السائق. الأسعار من kazan نفسه الذي
|
||||
-- تُسعَّر به الرحلة، عبر ride/pricing/pricing_helper.php.
|
||||
--
|
||||
-- يستبدل هذا الملف مقاربة "الرسم الثابت" السابقة: المبلغ لم يعد يُقرأ من
|
||||
-- طلب العميل (كان filterRequest("penalty_fee") بلا سقف ولا تحقق)، ولم يعد
|
||||
-- رقماً ثابتاً — صار محسوباً من بيانات مسجَّلة على الخادم.
|
||||
--
|
||||
-- ⚠️ هذا الملف لقاعدة primary (jorSiroDB) حصراً. الملف التوأم _ride.sql يُنفَّذ على قاعدة الرحلات (intaleq-ridesDB).
|
||||
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
-- ١) سياسة التعويض لكل دولة (قاعدة primary فقط — kazan يُقرأ عبر $con)
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
ALTER TABLE `kazan`
|
||||
ADD COLUMN `cancelCompensationCap` VARCHAR(10)
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci
|
||||
NOT NULL DEFAULT '0'
|
||||
COMMENT 'سقف تعويض عدم حضور الراكب بعملة الدولة. 0 = التعويض معطّل بالكامل'
|
||||
AFTER `startPrice`,
|
||||
ADD COLUMN `cancelMinWaitMinutes` TINYINT UNSIGNED
|
||||
NOT NULL DEFAULT 5
|
||||
COMMENT 'أقل دقائق انتظار بعد الوصول قبل أن يستحق السائق تعويضاً'
|
||||
AFTER `cancelCompensationCap`;
|
||||
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
-- ٢) بيانات الوصول والاقتراب (قاعدتا primary و ride)
|
||||
--
|
||||
-- هذان العمودان للتوثيق النهائي فقط ولا يُكتبان أثناء الرحلة: القيمتان
|
||||
-- تعيشان في هاش Redis نفسه الذي يستخدمه السوكِت لحالة الرحلة
|
||||
-- (ride:$id:state، الحقول pickup_approach_km و driver_arrived_at)،
|
||||
-- وتُثبَّتان هنا كتابةً واحدة مدمجة في جملة تحديث الحالة عند الإلغاء.
|
||||
-- أي: صفر كتابات إضافية على المسار الحار (القبول والوصول).
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
ALTER TABLE `ride`
|
||||
ADD COLUMN `driverArrivedAt` DATETIME NULL DEFAULT NULL
|
||||
COMMENT 'لحظة تأكيد وصول السائق لنقطة الالتقاط — يختمها الخادم بعد تحقق الموقع',
|
||||
ADD COLUMN `pickupApproachKm` FLOAT NOT NULL DEFAULT 0
|
||||
COMMENT 'المسافة من موقع السائق لحظة القبول إلى نقطة الالتقاط (كم)';
|
||||
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
-- ٣) تطبيق الإيقاف على الخادم (قاعدتا primary و ride)
|
||||
-- كان الإيقاف يُكتب في تخزين تطبيق السائق وحده، فيُفكّ بإعادة التثبيت.
|
||||
--
|
||||
-- مسار الفحص في acceptRide.php هو مفتاح Redis بـ TTL (driver:$id:blocked)
|
||||
-- فينتهي الإيقاف تلقائياً بلا كرون تنظيف وبلا استعلام على كل قبول.
|
||||
-- هذا العمود سجل دائم للمراجعة الإدارية، يُكتب في الإلغاء الثالث فقط.
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
ALTER TABLE `driver`
|
||||
ADD COLUMN `blockedUntil` DATETIME NULL DEFAULT NULL
|
||||
COMMENT 'إيقاف استقبال الرحلات حتى هذا الوقت (إلغاءات متكررة). NULL = غير موقوف';
|
||||
|
||||
-- للتراجع:
|
||||
-- ALTER TABLE `kazan` DROP COLUMN `cancelCompensationCap`, DROP COLUMN `cancelMinWaitMinutes`;
|
||||
-- ALTER TABLE `ride` DROP COLUMN `driverArrivedAt`, DROP COLUMN `pickupApproachKm`;
|
||||
-- ALTER TABLE `driver` DROP COLUMN `blockedUntil`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user