Update codebase
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -11,18 +11,16 @@ $password = filterRequest('password');
|
||||
$audience = filterRequest('aud') ?? 'siro-driver-android'; // الافتراضي
|
||||
$fingerprint = filterRequest('fingerPrint') ?? filterRequest('fingerprint');
|
||||
|
||||
// 1. تطبيق حد معدل الطلبات (Rate Limiting) للفاحصين: 3 محاولات بالدقيقة لكل IP
|
||||
// 1. حد معدل الطلبات مطبّق على الجميع (الحد مرفوع إلى 30/دقيقة في RateLimiter)
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'tester_login');
|
||||
|
||||
if (!$email || !$password) {
|
||||
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
// 2. التحقق من أن الحساب مخصص للفحص فقط (isTest check)
|
||||
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: '';
|
||||
$allowedEmails = array_filter(array_map('trim', explode(',', $allowedTesterEmailsEnv)));
|
||||
// 2. قائمة بيضاء صريحة لحسابات الفحص — مطابقة تامة فقط، لا مطابقة جزئية ولا مطابقة نطاق
|
||||
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: ($_ENV['ALLOWED_TESTER_EMAILS'] ?? '');
|
||||
$allowedEmails = array_filter(array_map(
|
||||
fn($e) => strtolower(trim($e)),
|
||||
explode(',', $allowedTesterEmailsEnv)
|
||||
));
|
||||
if (empty($allowedEmails)) {
|
||||
$allowedEmails = [
|
||||
'driver_tester@siromove.com',
|
||||
@@ -30,24 +28,21 @@ if (empty($allowedEmails)) {
|
||||
];
|
||||
}
|
||||
|
||||
$cleanEmail = strtolower(trim($email));
|
||||
$isTester = in_array($cleanEmail, $allowedEmails) ||
|
||||
substr($cleanEmail, -13) === '@siromove.com' ||
|
||||
str_contains($cleanEmail, 'tester') ||
|
||||
str_contains($cleanEmail, 'reviewer');
|
||||
$cleanEmail = strtolower(trim((string) $email));
|
||||
$isTester = in_array($cleanEmail, $allowedEmails, true);
|
||||
|
||||
// تشفير الإيميل لاستخدامه في الاستعلام
|
||||
$encryptedEmail = $encryptionHelper->encryptData($email);
|
||||
if (!$email || !$password) {
|
||||
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
|
||||
// Auto-seed/create tester driver logic removed for security
|
||||
|
||||
$encryptedEmail = $encryptionHelper->encryptData($email);
|
||||
global $blindIndex;
|
||||
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', $email) : null;
|
||||
|
||||
// SQL لاسترجاع المستخدم بناءً على البريد الإلكتروني المشفر أو الفهرس الأعمى
|
||||
$sql = "SELECT
|
||||
driver.*,
|
||||
phone_verification.is_verified,
|
||||
@@ -57,46 +52,47 @@ try {
|
||||
FROM driver
|
||||
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone_key
|
||||
LEFT JOIN CarRegistration ON CarRegistration.driverID = driver.id
|
||||
WHERE
|
||||
driver.email = :email OR (:email_bidx IS NOT NULL AND driver.email_bidx = :email_bidx)
|
||||
LIMIT 1";
|
||||
WHERE driver.email = :email";
|
||||
|
||||
$params = [':email' => $encryptedEmail];
|
||||
|
||||
if ($emailBidx !== null) {
|
||||
$sql .= " OR driver.email_bidx = :email_bidx";
|
||||
$params[':email_bidx'] = $emailBidx;
|
||||
}
|
||||
|
||||
$sql .= " LIMIT 1";
|
||||
|
||||
$stmt = $con->prepare($sql);
|
||||
$stmt->execute([':email' => $encryptedEmail, ':email_bidx' => $emailBidx]);
|
||||
$stmt->execute($params);
|
||||
|
||||
$data = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($data) {
|
||||
// التحقق من أن الحساب معلم كحساب فحص في قاعدة البيانات أو البيئة
|
||||
$isTestInDb = (isset($data['is_test']) && $data['is_test'] == 1) || (isset($data['isTest']) && $data['isTest'] == 1);
|
||||
if (!$isTestInDb && !$isTester) {
|
||||
jsonError("Access denied. Not a tester account.");
|
||||
exit();
|
||||
}
|
||||
// فحص الباسورد (في نظامنا، يمكن أن يكون الباسورد هو HMAC أو نص عادي للفاحصين)
|
||||
// لنفترض أن الفاحص له باسورد عادي أو مشفر بـ bcrypt
|
||||
if (password_verify($password, $data['password'])) {
|
||||
|
||||
if (password_verify($password, $data['password'] ?? '')) {
|
||||
unset($data['password']);
|
||||
|
||||
// فك تشفير الحقول الحساسة
|
||||
$data['phone'] = $encryptionHelper->decryptData($data['phone']);
|
||||
$data['email'] = $encryptionHelper->decryptData($data['email']);
|
||||
$data['gender'] = $encryptionHelper->decryptData($data['gender']);
|
||||
$data['birthdate'] = $encryptionHelper->decryptData($data['birthdate']);
|
||||
$data['site'] = $encryptionHelper->decryptData($data['site']);
|
||||
$data['first_name'] = $encryptionHelper->decryptData($data['first_name']);
|
||||
$data['last_name'] = $encryptionHelper->decryptData($data['last_name']);
|
||||
if(isset($data['employmentType'])) $data['employmentType'] = $encryptionHelper->decryptData($data['employmentType']);
|
||||
if(isset($data['maritalStatus'])) $data['maritalStatus'] = $encryptionHelper->decryptData($data['maritalStatus']);
|
||||
if(isset($data['phone'])) $data['phone'] = $encryptionHelper->decryptData($data['phone']);
|
||||
if(isset($data['email'])) $data['email'] = $encryptionHelper->decryptData($data['email']);
|
||||
if(isset($data['gender'])) $data['gender'] = $encryptionHelper->decryptData($data['gender']);
|
||||
if(isset($data['birthdate'])) $data['birthdate'] = $encryptionHelper->decryptData($data['birthdate']);
|
||||
if(isset($data['site'])) $data['site'] = $encryptionHelper->decryptData($data['site']);
|
||||
if(isset($data['first_name'])) $data['first_name'] = $encryptionHelper->decryptData($data['first_name']);
|
||||
if(isset($data['last_name'])) $data['last_name'] = $encryptionHelper->decryptData($data['last_name']);
|
||||
|
||||
// توليد الـ JWT بصلاحية (tester) لتميزهم عن السائقين الفعليين
|
||||
$jwtService = new JwtService($redis);
|
||||
$jwt = $jwtService->generateAccessToken($data['id'], 'tester', $audience, $fingerprint);
|
||||
|
||||
echo json_encode([
|
||||
"status" => "success",
|
||||
"jwt" => $jwt,
|
||||
"data" => [$data] // مطابق لنسق التطبيق الذي يتوقع مصفوفة
|
||||
"data" => [$data]
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
jsonError("Incorrect password.");
|
||||
@@ -104,9 +100,9 @@ try {
|
||||
} else {
|
||||
jsonError("User does not exist.");
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("[Tester Login Error] " . $e->getMessage());
|
||||
jsonError("Server error occurred.");
|
||||
} catch (Throwable $e) {
|
||||
error_log("[Tester Login Error] " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
|
||||
jsonError("Server error occurred: " . $e->getMessage() . " in " . basename($e->getFile()) . ":" . $e->getLine());
|
||||
} finally {
|
||||
$stmt = null;
|
||||
$con = null;
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -177,8 +240,10 @@ try {
|
||||
$isRegistered = false;
|
||||
$driverData = null;
|
||||
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM driver WHERE phone = ?");
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number)]);
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM driver WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)");
|
||||
global $blindIndex;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone_number) : null;
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number), $phoneBidx, $phoneBidx]);
|
||||
$driver = $chkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
// Generate driverID for unregistered users (hash of phone)
|
||||
@@ -273,8 +338,10 @@ try {
|
||||
$passengerData = null;
|
||||
$passengerID = '';
|
||||
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM passengers WHERE phone = ?");
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number)]);
|
||||
$chkStmt = $con->prepare("SELECT id, first_name, last_name, email, phone FROM passengers WHERE phone = ? OR (? IS NOT NULL AND phone_bidx = ?)");
|
||||
global $blindIndex;
|
||||
$phoneBidx = $blindIndex ? $blindIndex->index('passengers.phone', $phone_number) : null;
|
||||
$chkStmt->execute([$encryptionHelper->encryptData($phone_number), $phoneBidx, $phoneBidx]);
|
||||
$passenger = $chkStmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($passenger) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,18 +9,16 @@ $password = filterRequest("password");
|
||||
$fingerprint = filterRequest('fingerPrint') ?? filterRequest('fingerprint');
|
||||
$audience = filterRequest('aud') ?: 'siro_passenger';
|
||||
|
||||
// 1. تطبيق حد معدل الطلبات (Rate Limiting) للفاحصين: 3 محاولات بالدقيقة لكل IP
|
||||
// 1. حد معدل الطلبات مطبّق على الجميع (الحد مرفوع إلى 30/دقيقة في RateLimiter)
|
||||
$rateLimiter = new RateLimiter($redis);
|
||||
$rateLimiter->enforce(RateLimiter::identifier(), 'tester_login');
|
||||
|
||||
if (!$email || !$password) {
|
||||
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
// 2. التحقق من أن الحساب مخصص للفحص فقط (isTest check)
|
||||
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: '';
|
||||
$allowedEmails = array_filter(array_map('trim', explode(',', $allowedTesterEmailsEnv)));
|
||||
// 2. قائمة بيضاء صريحة لحسابات الفحص — مطابقة تامة فقط، لا مطابقة جزئية ولا مطابقة نطاق
|
||||
$allowedTesterEmailsEnv = getenv('ALLOWED_TESTER_EMAILS') ?: ($_ENV['ALLOWED_TESTER_EMAILS'] ?? '');
|
||||
$allowedEmails = array_filter(array_map(
|
||||
fn($e) => strtolower(trim($e)),
|
||||
explode(',', $allowedTesterEmailsEnv)
|
||||
));
|
||||
if (empty($allowedEmails)) {
|
||||
$allowedEmails = [
|
||||
'driver_tester@siromove.com',
|
||||
@@ -28,12 +26,13 @@ if (empty($allowedEmails)) {
|
||||
];
|
||||
}
|
||||
|
||||
$cleanEmail = strtolower(trim((string) $email));
|
||||
$isTester = in_array($cleanEmail, $allowedEmails, true);
|
||||
|
||||
$cleanEmail = strtolower(trim($email));
|
||||
$isTester = in_array($cleanEmail, $allowedEmails) ||
|
||||
substr($cleanEmail, -13) === '@siromove.com' ||
|
||||
str_contains($cleanEmail, 'tester') ||
|
||||
str_contains($cleanEmail, 'reviewer');
|
||||
if (!$email || !$password) {
|
||||
echo json_encode(["status" => "failure", "message" => "Email and password are required"]);
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$con = Database::get('main');
|
||||
@@ -121,7 +120,7 @@ try {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
"status" => "failure",
|
||||
"message" => "Server error: " . $e->getMessage() . " in " . basename($e->getFile()) . " on line " . $e->getLine()
|
||||
"message" => "Server error. Please try again."
|
||||
]);
|
||||
}
|
||||
exit();
|
||||
|
||||
@@ -63,7 +63,7 @@ try {
|
||||
$firstName_encrypted = $encryptionHelper->encryptData($firstName);
|
||||
$lastName_encrypted = $encryptionHelper->encryptData($lastName);
|
||||
$email_encrypted = $encryptionHelper->encryptData($email);
|
||||
$uniqueId = substr(md5($phoneNumber), 0, 20);
|
||||
$uniqueId = substr(md5($phoneNumber), 0, 16);
|
||||
$password_hashed = password_hash($email . $uniqueId, PASSWORD_DEFAULT);
|
||||
$unknown_encrypted = $encryptionHelper->encryptData("unknown yet");
|
||||
|
||||
@@ -77,12 +77,13 @@ try {
|
||||
// (مهلة أوسع من صلاحية الرمز نفسه [5 دقائق] لإعطاء وقت كافٍ لإكمال
|
||||
// نموذج التسجيل بعد التحقق مباشرة).
|
||||
$step = 4.5;
|
||||
$otpSearchKey = otpPhoneKey($phoneNumber);
|
||||
$verifyCheckStmt = $con->prepare(
|
||||
"SELECT id FROM phone_verification_passenger
|
||||
WHERE phone_number = ? AND verified = 1 AND created_at > DATE_SUB(NOW(), INTERVAL 30 MINUTE)
|
||||
LIMIT 1"
|
||||
);
|
||||
$verifyCheckStmt->execute([$phoneNumber_encrypted]);
|
||||
$verifyCheckStmt->execute([$otpSearchKey]);
|
||||
if ($verifyCheckStmt->rowCount() === 0) {
|
||||
error_log("$logTag Step 4.5 Error: Phone number not verified via OTP.");
|
||||
jsonError("Phone number must be verified before registration.");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -156,7 +156,9 @@ class JwtService
|
||||
|
||||
// 3. Issuer (Only check if configured)
|
||||
if (!empty($this->issuer) && ($decoded->iss ?? '') !== $this->issuer) {
|
||||
self::abort(401, 'Invalid token issuer: expected ' . $this->issuer . ' but got ' . ($decoded->iss ?? 'none'));
|
||||
// التفاصيل في اللوج فقط — لا تُكشف في الرد.
|
||||
error_log('[SECURITY] Issuer mismatch | expected: ' . $this->issuer . ' | got: ' . ($decoded->iss ?? 'none'));
|
||||
self::abort(401, 'Invalid token issuer');
|
||||
}
|
||||
|
||||
// 3.1 App Signature Verification (Service Only)
|
||||
@@ -228,8 +230,15 @@ class JwtService
|
||||
}
|
||||
|
||||
if ($fpInToken === null || $fpHeader === null) {
|
||||
$allHeaders = json_encode(getallheaders());
|
||||
error_log("[SECURITY] Fingerprint missing | user: $userId | fpInToken: " . ($fpInToken ?? 'NULL') . " | fpHeader: " . ($fpHeader ?? 'NULL') . " | Headers: $allHeaders");
|
||||
// ملاحظة: ممنوع تسجيل الهيدرز كاملة — كانت تُسرّب الـ
|
||||
// Authorization: Bearer <token> بالنص الصريح إلى error_log.
|
||||
// نسجّل فقط أيّ الطرفين ناقص، دون أي قيمة.
|
||||
error_log(sprintf(
|
||||
"[SECURITY] Fingerprint missing | user: %s | inToken: %s | inHeader: %s",
|
||||
$userId,
|
||||
$fpInToken === null ? 'no' : 'yes',
|
||||
$fpHeader === null ? 'no' : 'yes'
|
||||
));
|
||||
self::abort(403, 'Device verification required');
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class RateLimiter
|
||||
// حدود مختلفة لكل نوع endpoint
|
||||
private const LIMITS = [
|
||||
'login' => ['requests' => 5, 'window' => 60], // 5 محاولات / دقيقة
|
||||
'tester_login' => ['requests' => 3, 'window' => 60], // 3 محاولات / دقيقة
|
||||
'tester_login' => ['requests' => 30, 'window' => 60], // 30 محاولة / دقيقة (مراجعو المتاجر يكرّرون الدخول بسرعة)
|
||||
'otp' => ['requests' => 3, 'window' => 300], // 3 محاولات / 5 دقائق
|
||||
'register' => ['requests' => 3, 'window' => 3600], // 3 محاولات / ساعة
|
||||
'api' => ['requests' => 180, 'window' => 60], // 180 طلب / دقيقة (الإنتاج الرسمى)
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ class Database
|
||||
'user' => 'DB_TRANSIT_USER',
|
||||
'pass' => 'DB_TRANSIT_PASS',
|
||||
],
|
||||
'food' => [
|
||||
'name' => 'DB_FOOD_NAME',
|
||||
'host' => 'DB_FOOD_HOST',
|
||||
'user' => 'DB_FOOD_USER',
|
||||
'pass' => 'DB_FOOD_PASS',
|
||||
],
|
||||
];
|
||||
|
||||
public static function get(string $name = 'main'): PDO
|
||||
@@ -67,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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// food/admin/merchant_approve.php — اعتماد أو رفض مطعم بحالة pending_approval
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
|
||||
$merchantId = filterRequest('merchant_id', 'int');
|
||||
$action = filterRequest('action'); // 'approve' | 'reject'
|
||||
if (!$merchantId || !in_array($action, ['approve', 'reject'], true)) {
|
||||
jsonError('merchant_id and action (approve|reject) are required');
|
||||
}
|
||||
|
||||
$st = $food_con->prepare("SELECT id, status FROM food_merchants WHERE id=? LIMIT 1");
|
||||
$st->execute([$merchantId]);
|
||||
$merchant = $st->fetch();
|
||||
if (!$merchant) jsonError('Merchant not found', 404);
|
||||
if ($merchant['status'] !== 'pending_approval') jsonError('Merchant is not pending approval', 409);
|
||||
|
||||
$newStatus = $action === 'approve' ? 'active' : 'rejected';
|
||||
|
||||
$food_con->prepare(
|
||||
"UPDATE food_merchants SET status=?, approved_by=?, approved_at=NOW() WHERE id=?"
|
||||
)->execute([$newStatus, $food_admin_id, $merchantId]);
|
||||
|
||||
jsonSuccess(['merchant_id' => $merchantId, 'status' => $newStatus]);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// food/admin/merchant_create.php — إنشاء مطعم جديد بحالة pending_approval + حساب مالكه
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
|
||||
requireFoodFields(['name_ar', 'city', 'address', 'latitude', 'longitude', 'owner_name', 'owner_phone', 'owner_password']);
|
||||
|
||||
$nameAr = filterRequest('name_ar');
|
||||
$nameEn = filterRequest('name_en');
|
||||
$city = filterRequest('city');
|
||||
$address = filterRequest('address');
|
||||
$lat = filterRequest('latitude', 'float');
|
||||
$lng = filterRequest('longitude', 'float');
|
||||
$category = filterRequest('category');
|
||||
$commission = filterRequest('commission_percent', 'float') ?? (float)(getenv('FOOD_COMMISSION_PERCENT') ?: 15);
|
||||
|
||||
$ownerName = filterRequest('owner_name');
|
||||
$ownerPhone = normalizePhone(filterRequest('owner_phone'));
|
||||
$ownerPassword = filterRequest('owner_password');
|
||||
|
||||
if (strlen($ownerPassword) < 8) jsonError('owner_password must be at least 8 characters');
|
||||
|
||||
$dupSt = $food_con->prepare("SELECT id FROM food_merchant_users WHERE phone=? LIMIT 1");
|
||||
$dupSt->execute([$ownerPhone]);
|
||||
if ($dupSt->fetch()) jsonError('A merchant account already uses this phone', 409);
|
||||
|
||||
$food_con->beginTransaction();
|
||||
try {
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_merchants
|
||||
(name_ar, name_en, city, address, latitude, longitude, category, commission_percent, status)
|
||||
VALUES (?,?,?,?,?,?,?,?,'pending_approval')"
|
||||
)->execute([$nameAr, $nameEn, $city, $address, $lat, $lng, $category, $commission]);
|
||||
|
||||
$merchantId = (int)$food_con->lastInsertId();
|
||||
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_merchant_users (merchant_id, name, phone, role, password_hash)
|
||||
VALUES (?,?,?,'owner',?)"
|
||||
)->execute([$merchantId, $ownerName, $ownerPhone, password_hash($ownerPassword, PASSWORD_DEFAULT)]);
|
||||
|
||||
$food_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$food_con->rollBack();
|
||||
appLog('[FOOD][ADMIN][merchant_create] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to create merchant', 500);
|
||||
}
|
||||
|
||||
jsonSuccess(['merchant_id' => $merchantId, 'status' => 'pending_approval'], 'Merchant created — awaiting approval');
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// food/admin/merchants.php — قائمة المطاعم لإدارة سيرو (فلترة حسب الحالة)
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
|
||||
$status = filterRequest('status') ?: 'all';
|
||||
$allowed = ['all', 'pending_approval', 'active', 'paused', 'suspended', 'rejected'];
|
||||
if (!in_array($status, $allowed, true)) $status = 'all';
|
||||
|
||||
$sql = "SELECT id, name_ar, name_en, city, category, status, commission_percent,
|
||||
rating_avg, rating_count, created_at, approved_at
|
||||
FROM food_merchants";
|
||||
$params = [];
|
||||
if ($status !== 'all') {
|
||||
$sql .= " WHERE status=?";
|
||||
$params[] = $status;
|
||||
}
|
||||
$sql .= " ORDER BY created_at DESC";
|
||||
|
||||
$st = $food_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
|
||||
jsonSuccess(['merchants' => $st->fetchAll()]);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// food/admin/payouts.php — تقرير تسويات المطاعم للفترة المطلوبة (توليد لا صرف آلي)
|
||||
//
|
||||
// ⚠️ هذا يُنتج تقريراً محاسبياً (food_merchant_payouts بحالة pending) — لا يحوّل
|
||||
// أموالاً فعلياً. الصرف الفعلي للمطاعم والسائقين خارج نطاق هذه الوحدة حتى يُؤكَّد
|
||||
// عقد S2S مخصص لذلك (انظر التنبيهات في food/functions.php حول foodWalletMove).
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
|
||||
$merchantId = filterRequest('merchant_id', 'int');
|
||||
$periodStart = filterRequest('period_start');
|
||||
$periodEnd = filterRequest('period_end');
|
||||
requireFoodFields(['merchant_id', 'period_start', 'period_end']);
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT COUNT(*) orders_count, COALESCE(SUM(items_total),0) gross_amount, COALESCE(SUM(commission_amount),0) commission_amount
|
||||
FROM food_orders
|
||||
WHERE merchant_id=? AND status='delivered' AND delivered_at BETWEEN ? AND ?"
|
||||
);
|
||||
$st->execute([$merchantId, $periodStart, $periodEnd]);
|
||||
$summary = $st->fetch();
|
||||
|
||||
$netPayout = (int)$summary['gross_amount'] - (int)$summary['commission_amount'];
|
||||
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_merchant_payouts (merchant_id, period_start, period_end, orders_count, gross_amount, commission_amount, net_payout, status)
|
||||
VALUES (?,?,?,?,?,?,?,'pending')"
|
||||
)->execute([
|
||||
$merchantId, $periodStart, $periodEnd, $summary['orders_count'],
|
||||
$summary['gross_amount'], $summary['commission_amount'], $netPayout,
|
||||
]);
|
||||
|
||||
jsonSuccess([
|
||||
'merchant_id' => $merchantId,
|
||||
'orders_count' => (int)$summary['orders_count'],
|
||||
'gross_amount' => (int)$summary['gross_amount'],
|
||||
'commission_amount' => (int)$summary['commission_amount'],
|
||||
'net_payout' => $netPayout,
|
||||
'status' => 'pending',
|
||||
], 'Payout report generated');
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
// food/cart/quote.php — يحسب السعر من الخادم فقط ويوقّعه لمدة 10 دقائق
|
||||
// body: merchant_id, items:[{item_id, quantity, options:[{option_group_id, choice_ids:[]}]}]
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$merchantId = filterRequest('merchant_id', 'int');
|
||||
$itemsRaw = json_decode(filterRequest('items') ?? '[]', true);
|
||||
if (!$merchantId || !$itemsRaw || !is_array($itemsRaw)) {
|
||||
jsonError('merchant_id and items are required');
|
||||
}
|
||||
|
||||
$merchantSt = $food_con->prepare(
|
||||
"SELECT id, min_order_amount, is_open_override, working_hours FROM food_merchants WHERE id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$merchantSt->execute([$merchantId]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
if (!$merchant) jsonError('Merchant not found', 404);
|
||||
if (!foodIsMerchantOpen($merchant)) jsonError('Merchant is currently closed', 409);
|
||||
|
||||
$lines = [];
|
||||
$itemsTotal = 0;
|
||||
|
||||
foreach ($itemsRaw as $line) {
|
||||
$itemId = (int)($line['item_id'] ?? 0);
|
||||
$quantity = max(1, (int)($line['quantity'] ?? 1));
|
||||
if (!$itemId) jsonError('Invalid item in cart');
|
||||
|
||||
$itemSt = $food_con->prepare(
|
||||
"SELECT id, merchant_id, name_ar, price, is_available FROM food_menu_items WHERE id=? LIMIT 1"
|
||||
);
|
||||
$itemSt->execute([$itemId]);
|
||||
$item = $itemSt->fetch();
|
||||
if (!$item || (int)$item['merchant_id'] !== $merchantId) jsonError("Item $itemId does not belong to this merchant", 400);
|
||||
if (!$item['is_available']) jsonError("{$item['name_ar']} is currently unavailable", 409);
|
||||
|
||||
$optionsSt = $food_con->prepare("SELECT id, choices, is_required, max_select FROM food_item_options WHERE item_id=?");
|
||||
$optionsSt->execute([$itemId]);
|
||||
$optionGroups = $optionsSt->fetchAll();
|
||||
|
||||
$chosenOptionsOut = [];
|
||||
$optionsTotalPerUnit = 0;
|
||||
$requestedGroups = $line['options'] ?? [];
|
||||
|
||||
foreach ($optionGroups as $group) {
|
||||
$choices = json_decode($group['choices'], true) ?: [];
|
||||
$choiceById = array_column($choices, null, 'id');
|
||||
$requested = null;
|
||||
foreach ($requestedGroups as $rg) {
|
||||
if ((int)($rg['option_group_id'] ?? 0) === (int)$group['id']) { $requested = $rg; break; }
|
||||
}
|
||||
$choiceIds = $requested['choice_ids'] ?? [];
|
||||
|
||||
if ($group['is_required'] && empty($choiceIds)) {
|
||||
jsonError("Required option group missing for item {$item['name_ar']}", 400);
|
||||
}
|
||||
if (count($choiceIds) > (int)$group['max_select']) {
|
||||
jsonError("Too many choices selected for item {$item['name_ar']}", 400);
|
||||
}
|
||||
|
||||
foreach ($choiceIds as $cid) {
|
||||
if (!isset($choiceById[$cid])) jsonError("Invalid option choice for item {$item['name_ar']}", 400);
|
||||
$optionsTotalPerUnit += (int)$choiceById[$cid]['price'];
|
||||
$chosenOptionsOut[] = ['group_id' => (int)$group['id'], 'choice_id' => $cid, 'price' => (int)$choiceById[$cid]['price']];
|
||||
}
|
||||
}
|
||||
|
||||
$unitPrice = (int)$item['price'] + $optionsTotalPerUnit;
|
||||
$lineTotal = $unitPrice * $quantity;
|
||||
$itemsTotal += $lineTotal;
|
||||
|
||||
$lines[] = [
|
||||
'item_id' => $itemId,
|
||||
'name_ar_snapshot' => $item['name_ar'],
|
||||
'unit_price' => $unitPrice,
|
||||
'quantity' => $quantity,
|
||||
'options' => $chosenOptionsOut,
|
||||
'line_total' => $lineTotal,
|
||||
];
|
||||
}
|
||||
|
||||
if ($itemsTotal < (int)$merchant['min_order_amount']) {
|
||||
jsonError('Order does not meet minimum order amount', 409, ['min_order_amount' => (int)$merchant['min_order_amount']]);
|
||||
}
|
||||
|
||||
// رسم التوصيل — ثابت من env حتى يُربط بمحرك التسعير القائم في مرحلة لاحقة
|
||||
$deliveryFee = (int)(getenv('FOOD_DELIVERY_BASE_FEE') ?: 1000);
|
||||
$serviceFee = 0;
|
||||
$grandTotal = $itemsTotal + $deliveryFee + $serviceFee;
|
||||
|
||||
$quote = [
|
||||
'merchant_id' => $merchantId,
|
||||
'passenger_id' => $food_passenger_id,
|
||||
'lines' => $lines,
|
||||
'items_total' => $itemsTotal,
|
||||
'delivery_fee' => $deliveryFee,
|
||||
'service_fee' => $serviceFee,
|
||||
'grand_total' => $grandTotal,
|
||||
];
|
||||
|
||||
$token = foodSignQuote($quote);
|
||||
|
||||
jsonSuccess([
|
||||
'quote_token' => $token,
|
||||
'items_total' => $itemsTotal,
|
||||
'delivery_fee' => $deliveryFee,
|
||||
'service_fee' => $serviceFee,
|
||||
'grand_total' => $grandTotal,
|
||||
'lines' => $lines,
|
||||
'expires_in' => 600,
|
||||
]);
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/connect_admin.php — بوابة إدارة سيرو (اعتماد المطاعم، التسويات...)
|
||||
// يستخدم JWT الإداري نفسه المستخدم في backend/Admin (role admin/super_admin)
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
$jwtService = new JwtService($redis);
|
||||
$decoded = $jwtService->authenticate();
|
||||
|
||||
$food_admin_role = $decoded->role ?? '';
|
||||
if (!in_array($food_admin_role, ['admin', 'super_admin'], true)) {
|
||||
jsonError('Forbidden — admin token required', 403);
|
||||
}
|
||||
$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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/connect_app.php — بوابة الزبون (تبويب «طعام» داخل siro_rider)
|
||||
// يستخدم JWT الرئيسي نفسه — الزبون هو الراكب نفسه، لا حساب ثانٍ
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
if (getenv('FOOD_ENABLED') === 'false') {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Rate limiting — نفس حد API العادي
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
// JWT المعتاد — راكب فقط (لا يُقبل توكن سائق هنا)
|
||||
$jwtService = new JwtService($redis);
|
||||
$decoded = $jwtService->authenticate();
|
||||
|
||||
if (($decoded->role ?? '') !== 'passenger') {
|
||||
jsonError('Forbidden — passenger token required', 403);
|
||||
}
|
||||
$food_passenger_id = (string)($decoded->user_id ?? '');
|
||||
|
||||
// اتصال قاعدة بيانات food فقط — ممنوع Database::get('main') في ملفات food/
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/connect_courier.php — بوابة السائق (نوع مهمة «توصيل» داخل تدفّق العروض القائم)
|
||||
// يستخدم JWT الرئيسي نفسه — السائق هو الكابتن نفسه، لا حساب ثانٍ
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
if (getenv('FOOD_ENABLED') === 'false') {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
$jwtService = new JwtService($redis);
|
||||
$decoded = $jwtService->authenticate();
|
||||
|
||||
if (($decoded->role ?? '') !== 'driver') {
|
||||
jsonError('Forbidden — driver token required', 403);
|
||||
}
|
||||
$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;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/connect_merchant.php — بوابة لوحة المطعم (ويب متجاوب)
|
||||
// المصادقة عبر session token (هاتف + كلمة مرور) — مستقلة عن JWT
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
if (getenv('FOOD_ENABLED') === 'false') {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CORS للوحة المطعم
|
||||
$merchantOrigins = array_map('trim', explode(',',
|
||||
getenv('FOOD_MERCHANT_ORIGINS') ?: 'https://food-merchant.siromove.com,https://admin.siromove.com'
|
||||
));
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (in_array($origin, $merchantOrigins)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
$limiter = new RateLimiter($redis);
|
||||
$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;
|
||||
}
|
||||
|
||||
// التحقق من الـ session (يُعيد ['merchant_user_id'=>X, 'merchant_id'=>Y])
|
||||
$food_merchant_session = foodAuthMerchant();
|
||||
$food_merchant_user_id = (int)$food_merchant_session['merchant_user_id'];
|
||||
$food_merchant_id = (int)$food_merchant_session['merchant_id'];
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
// food/courier/active.php — طلبات التوصيل الحالية للسائق (polling fallback + شاشة المهمة)
|
||||
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, 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
|
||||
WHERE o.courier_id = ? AND o.status IN ('courier_assigned','picked_up')
|
||||
ORDER BY o.courier_assigned_at ASC"
|
||||
);
|
||||
$st->execute([$food_courier_id]);
|
||||
$orders = $st->fetchAll();
|
||||
|
||||
// بيانات الزبون (العنوان) تظهر فقط بعد courier_assigned وتُحجب بعد delivered — لا نُعيد أي طلب مسلَّم هنا أصلاً
|
||||
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);
|
||||
|
||||
jsonSuccess(['orders' => $orders]);
|
||||
@@ -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'],
|
||||
]);
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
// food/courier/delivered.php — تسليم الطلب — تثبيت المال (capture) وقيد أرباح السائق
|
||||
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);
|
||||
food_transition_status($orderId, 'delivered', 'courier', $food_courier_id);
|
||||
|
||||
if ($order['payment_method'] === 'wallet') {
|
||||
// المبلغ خُصم بالكامل عند الإنشاء (انظر ملاحظة foodWalletMove) — capture هنا تسجيل محاسبي فقط
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'capture',?,'success')"
|
||||
)->execute([$orderId, (int)$order['grand_total']]);
|
||||
} else {
|
||||
// نقداً: السائق حصّل grand_total كاملاً من الزبون. يحتفظ بـ delivery_fee (أجرته)
|
||||
// ويبقى ديناً عليه الباقي (items_total+service_fee — حصة المطعم والمنصة) حتى يُسوّى
|
||||
// إدارياً. هذا قيد محاسبي فقط هنا — لا حركة مالية آلية فعلية بعد (انظر التنبيه في
|
||||
// admin/payouts.php حول عدم وجود عقد API مؤكد لتحصيل ديون السائق النقدية آلياً).
|
||||
$courierOwed = (int)$order['grand_total'] - (int)$order['delivery_fee'];
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'cash_settlement',?,'pending')"
|
||||
)->execute([$orderId, $courierOwed]);
|
||||
}
|
||||
|
||||
// قيد أرباح السائق (أجرة التوصيل) — مُتراكم عمداً بقرار المالك: الأجرة
|
||||
// تُصرف دورياً مع مقاصّة دَين النقد، لا فورياً عند كل تسليم. صرفها منفصلة
|
||||
// عن الدَين يعني الدفع للسائق بينما هو يحمل مال المنصة من الطلبات النقدية.
|
||||
//
|
||||
// التسوية في 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']]);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'delivered']);
|
||||
@@ -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,76 @@
|
||||
<?php
|
||||
// food/courier/offer_respond.php — قبول/رفض عرض توصيل
|
||||
// body: order_id, response ('accept'|'reject')
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$response = filterRequest('response');
|
||||
if (!$orderId || !in_array($response, ['accept', 'reject'], true)) {
|
||||
jsonError('order_id and response (accept|reject) are required');
|
||||
}
|
||||
|
||||
$assignSt = $food_con->prepare(
|
||||
"SELECT id FROM food_courier_assignments WHERE order_id=? AND courier_id=? AND status='offered'
|
||||
ORDER BY offered_at DESC LIMIT 1"
|
||||
);
|
||||
$assignSt->execute([$orderId, $food_courier_id]);
|
||||
$assignment = $assignSt->fetch();
|
||||
if (!$assignment) jsonError('No pending offer for this order', 404);
|
||||
|
||||
if ($response === 'reject') {
|
||||
$food_con->prepare("UPDATE food_courier_assignments SET status='rejected', responded_at=NOW() WHERE id=?")
|
||||
->execute([$assignment['id']]);
|
||||
|
||||
// أعد المحاولة على مرشّح آخر — يعيد استخدام نفس منطق ready.php
|
||||
$orderSt = $food_con->prepare("SELECT merchant_id FROM food_orders WHERE id=? AND status='ready'");
|
||||
$orderSt->execute([$orderId]);
|
||||
if ($order = $orderSt->fetch()) {
|
||||
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
|
||||
$merchantSt->execute([$order['merchant_id']]);
|
||||
if ($merchant = $merchantSt->fetch()) {
|
||||
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
|
||||
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
|
||||
$priorSt->execute([$orderId]);
|
||||
$prior = array_column($priorSt->fetchAll(), 'courier_id');
|
||||
foreach ($candidates as $candidateId) {
|
||||
if (in_array($candidateId, $prior, true)) continue;
|
||||
foodOfferOrderToCourier($orderId, $candidateId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'response' => 'rejected']);
|
||||
}
|
||||
|
||||
// accept — القفل الذرّي يمنع سباق القبول لو انتهت مهلة سابقة وعُرض على اثنين معاً
|
||||
if (!foodLockOrderForCourier($orderId, $food_courier_id)) {
|
||||
$owner = foodOrderLockOwner($orderId);
|
||||
if ($owner !== $food_courier_id) {
|
||||
jsonError('Order was already claimed by another courier', 409);
|
||||
}
|
||||
}
|
||||
|
||||
// الطلب لم يُسند لأي سائق بعد عند هذه النقطة — لا فحص ملكية هنا، فقط فحص الحالة أدناه
|
||||
$orderSt = $food_con->prepare("SELECT status FROM food_orders WHERE id=? LIMIT 1");
|
||||
$orderSt->execute([$orderId]);
|
||||
$orderRow = $orderSt->fetch();
|
||||
if (!$orderRow || $orderRow['status'] !== 'ready') {
|
||||
jsonError('Order is no longer available for assignment', 409);
|
||||
}
|
||||
|
||||
$food_con->beginTransaction();
|
||||
try {
|
||||
$food_con->prepare("UPDATE food_orders SET courier_id=? WHERE id=?")->execute([$food_courier_id, $orderId]);
|
||||
$food_con->prepare("UPDATE food_courier_assignments SET status='accepted', responded_at=NOW() WHERE id=?")
|
||||
->execute([$assignment['id']]);
|
||||
$food_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$food_con->rollBack();
|
||||
appLog('[FOOD][COURIER][offer_respond] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to accept offer', 500);
|
||||
}
|
||||
|
||||
food_transition_status($orderId, 'courier_assigned', 'courier', $food_courier_id);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'response' => 'accepted']);
|
||||
@@ -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]);
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// food/courier/pending_offers.php — عروض التوصيل المعروضة على هذا السائق حالياً
|
||||
// (polling fallback — food_socket يدفع 'food_delivery_offer' لحظياً، وهذا احتياطي
|
||||
// لو انقطع اتصال السوكيت أو كان التطبيق لا يحمل اتصالاً حياً بالسوكيت)
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT a.order_id, a.offered_at
|
||||
FROM food_courier_assignments a
|
||||
JOIN food_orders o ON o.id = a.order_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]);
|
||||
|
||||
// نفس حمولة مسار السوكيت حرفياً (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,11 @@
|
||||
<?php
|
||||
// food/courier/picked_up.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);
|
||||
food_transition_status($orderId, 'picked_up', 'courier', $food_courier_id);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'picked_up']);
|
||||
@@ -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'],
|
||||
]);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// food/courier/toggle_availability.php — تفعيل/إيقاف "وضع التوصيل" للسائق
|
||||
// السائق لا يظهر لعروض التوصيل إلا إذا كان أيضاً متاحاً فعلياً في geo:drivers:available
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$enable = filterRequest('enabled', 'bool');
|
||||
if ($enable === null) jsonError('enabled (true|false) is required');
|
||||
|
||||
foodCourierOptIn($food_courier_id, $enable);
|
||||
|
||||
jsonSuccess(['courier_id' => $food_courier_id, 'delivery_mode_enabled' => $enable]);
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
// food/cron_order_timeouts.php — يُشغَّل كل دقيقة من crontab المضيف (مثل cron_* في transit)
|
||||
// 1) عروض توصيل معروضة أكثر من 20 ثانية بلا رد → timed_out + إعادة العرض لمرشح آخر
|
||||
// 2) طلبات pending أكثر من 5 دقائق بلا رد المطعم → إلغاء نظامي + استرجاع فوري
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
try { $food_con = Database::get('food'); }
|
||||
catch (Exception $e) { error_log('[FOOD][CRON] DB unavailable: ' . $e->getMessage()); exit(1); }
|
||||
|
||||
// ── 1) عروض منتهية المهلة ──
|
||||
$expiredSt = $food_con->query(
|
||||
"SELECT id, order_id, courier_id FROM food_courier_assignments
|
||||
WHERE status='offered' AND offered_at < (NOW() - INTERVAL 20 SECOND)"
|
||||
);
|
||||
foreach ($expiredSt->fetchAll() as $assignment) {
|
||||
$food_con->prepare("UPDATE food_courier_assignments SET status='timed_out', responded_at=NOW() WHERE id=?")
|
||||
->execute([$assignment['id']]);
|
||||
|
||||
$orderSt = $food_con->prepare("SELECT merchant_id FROM food_orders WHERE id=? AND status='ready'");
|
||||
$orderSt->execute([$assignment['order_id']]);
|
||||
$order = $orderSt->fetch();
|
||||
if (!$order) continue;
|
||||
|
||||
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
|
||||
$merchantSt->execute([$order['merchant_id']]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
if (!$merchant) continue;
|
||||
|
||||
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
|
||||
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
|
||||
$priorSt->execute([$assignment['order_id']]);
|
||||
$prior = array_column($priorSt->fetchAll(), 'courier_id');
|
||||
|
||||
foreach ($candidates as $candidateId) {
|
||||
if (in_array($candidateId, $prior, true)) continue;
|
||||
foodOfferOrderToCourier((int)$assignment['order_id'], $candidateId);
|
||||
error_log("[FOOD][CRON] Re-offered order {$assignment['order_id']} to courier {$candidateId} after timeout");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2) طلبات pending تجاوزت مهلة رد المطعم ──
|
||||
$stalePendingSt = $food_con->query(
|
||||
"SELECT id, passenger_id, payment_method, grand_total FROM food_orders
|
||||
WHERE status='pending' AND created_at < (NOW() - INTERVAL 5 MINUTE)"
|
||||
);
|
||||
foreach ($stalePendingSt->fetchAll() as $order) {
|
||||
food_transition_status((int)$order['id'], 'cancelled_system', 'system', 'cron', 'Merchant did not respond within timeout');
|
||||
|
||||
if ($order['payment_method'] === 'wallet') {
|
||||
$refunded = foodWalletMove(
|
||||
(string)$order['passenger_id'], (int)$order['grand_total'], 'add',
|
||||
"food-refund-{$order['id']}", "Food order #{$order['id']} auto-cancelled (merchant timeout)"
|
||||
);
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
|
||||
)->execute([$order['id'], (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
|
||||
|
||||
if (!$refunded) error_log("[FOOD][CRON] refund FAILED for auto-cancelled order {$order['id']} — needs manual reconciliation");
|
||||
}
|
||||
}
|
||||
|
||||
echo "food cron_order_timeouts done\n";
|
||||
@@ -0,0 +1,617 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/functions.php — دوال خاصة بوحدة طلبات الطعام فقط
|
||||
//
|
||||
// ما لا يوجد هنا (يُستخدم مباشرة من النظام الأصلي بعد bootstrap):
|
||||
// • filterRequest() ← core/helpers.php
|
||||
// • jsonSuccess() / jsonError() ← core/helpers.php
|
||||
// • appLog() / securityLog() ← core/helpers.php
|
||||
// • $redis ← مهيَّأ في bootstrap.php
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/Services/FcmService.php';
|
||||
|
||||
// ── حقول مطلوبة (غلاف رفيع يستخدم filterRequest + jsonError) ──
|
||||
function requireFoodFields(array $fields): void
|
||||
{
|
||||
foreach ($fields as $f) {
|
||||
if (filterRequest($f) === null) {
|
||||
jsonError("Missing required field: $f", 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ملكية المطعم — يتحقق أن merchant_user يخص هذا المطعم فعلاً ──
|
||||
function foodAssertMerchantOwnership(int $merchantUserId, int $merchantId): void
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare("SELECT id FROM food_merchant_users WHERE id=? AND merchant_id=? AND is_active=1 LIMIT 1");
|
||||
$st->execute([$merchantUserId, $merchantId]);
|
||||
if (!$st->fetch()) jsonError('Forbidden', 403);
|
||||
}
|
||||
|
||||
// ── ملكية الطلب — كل نقطة تأخذ order_id يجب أن تتحقق أن الفاعل يملكه ──
|
||||
// $actorType: customer | merchant | courier
|
||||
function foodAssertOrderOwnership(int $orderId, string $actorType, string $actorId): array
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare("SELECT * FROM food_orders WHERE id=? LIMIT 1");
|
||||
$st->execute([$orderId]);
|
||||
$order = $st->fetch();
|
||||
if (!$order) jsonError('Order not found', 404);
|
||||
|
||||
$ok = match ($actorType) {
|
||||
'customer' => (string)$order['passenger_id'] === $actorId,
|
||||
'courier' => (string)$order['courier_id'] === $actorId,
|
||||
'merchant' => (string)$order['merchant_id'] === $actorId,
|
||||
default => false,
|
||||
};
|
||||
if (!$ok) {
|
||||
appLog("[FOOD][IDOR] actor_type=$actorType actor_id=$actorId tried order_id=$orderId", 'WARNING');
|
||||
jsonError('Forbidden', 403);
|
||||
}
|
||||
return $order;
|
||||
}
|
||||
|
||||
// ── آلة الحالة — كل انتقال يمر من هنا فقط، لا UPDATE مبعثر ──
|
||||
const FOOD_STATUS_TRANSITIONS = [
|
||||
'pending' => ['merchant_accepted', 'rejected', 'cancelled_by_customer', 'cancelled_system'],
|
||||
'merchant_accepted' => ['preparing', 'cancelled_by_merchant', 'cancelled_system'],
|
||||
'preparing' => ['ready', 'cancelled_by_merchant', 'cancelled_system'],
|
||||
'ready' => ['courier_assigned', 'cancelled_system'],
|
||||
'courier_assigned' => ['picked_up', 'cancelled_system'],
|
||||
'picked_up' => ['delivered'],
|
||||
'delivered' => [],
|
||||
'rejected' => [],
|
||||
'cancelled_by_customer' => [],
|
||||
'cancelled_by_merchant' => [],
|
||||
'cancelled_system' => [],
|
||||
];
|
||||
|
||||
function food_transition_status(int $orderId, string $toStatus, string $actorType, string $actorId, ?string $note = null): void
|
||||
{
|
||||
$con = Database::get('food');
|
||||
|
||||
$con->beginTransaction();
|
||||
try {
|
||||
$st = $con->prepare("SELECT status FROM food_orders WHERE id=? FOR UPDATE");
|
||||
$st->execute([$orderId]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) { $con->rollBack(); jsonError('Order not found', 404); }
|
||||
|
||||
$fromStatus = $row['status'];
|
||||
$allowed = FOOD_STATUS_TRANSITIONS[$fromStatus] ?? [];
|
||||
if (!in_array($toStatus, $allowed, true)) {
|
||||
$con->rollBack();
|
||||
jsonError("Invalid status transition: $fromStatus -> $toStatus", 409);
|
||||
}
|
||||
|
||||
$timestampColumn = match ($toStatus) {
|
||||
'merchant_accepted' => 'merchant_accepted_at',
|
||||
'ready' => 'ready_at',
|
||||
'courier_assigned' => 'courier_assigned_at',
|
||||
'picked_up' => 'picked_up_at',
|
||||
'delivered' => 'delivered_at',
|
||||
default => str_starts_with($toStatus, 'cancelled') || $toStatus === 'rejected' ? 'cancelled_at' : null,
|
||||
};
|
||||
|
||||
$sql = "UPDATE food_orders SET status=?" . ($timestampColumn ? ", {$timestampColumn}=NOW()" : '') . " WHERE id=?";
|
||||
$params = [$toStatus, $orderId];
|
||||
$con->prepare($sql)->execute($params);
|
||||
|
||||
$con->prepare(
|
||||
"INSERT INTO food_order_status_log (order_id, from_status, to_status, actor_type, actor_id, note)
|
||||
VALUES (?,?,?,?,?,?)"
|
||||
)->execute([$orderId, $fromStatus, $toStatus, $actorType, $actorId, $note]);
|
||||
|
||||
$con->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
appLog('[FOOD][STATUS] ' . $e->getMessage(), 'ERROR');
|
||||
throw $e;
|
||||
}
|
||||
|
||||
foodNotifyOrderEvent($orderId, $toStatus);
|
||||
}
|
||||
|
||||
// ── إشعارات — سوكيت (internal HTTP push، نفس نمط passenger_socket) + FCM ──
|
||||
function foodNotifyOrderEvent(int $orderId, string $status): void
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare("SELECT passenger_id, merchant_id, courier_id FROM food_orders WHERE id=?");
|
||||
$st->execute([$orderId]);
|
||||
$order = $st->fetch();
|
||||
if (!$order) return;
|
||||
|
||||
foodPushToSocket('order_status_update', [
|
||||
'order_id' => $orderId,
|
||||
'status' => $status,
|
||||
'passenger_id' => (string)$order['passenger_id'],
|
||||
'merchant_id' => (int)$order['merchant_id'],
|
||||
'courier_id' => $order['courier_id'] ? (string)$order['courier_id'] : null,
|
||||
]);
|
||||
|
||||
$titles = [
|
||||
'merchant_accepted' => 'المطعم قبل طلبك',
|
||||
'preparing' => 'جاري تحضير طلبك',
|
||||
'ready' => 'طلبك جاهز، بانتظار السائق',
|
||||
'courier_assigned' => 'تم تعيين سائق لتوصيل طلبك',
|
||||
'picked_up' => 'السائق استلم طلبك وفي الطريق إليك',
|
||||
'delivered' => 'تم تسليم طلبك، بالهنا والشفا',
|
||||
'rejected' => 'اعتذر المطعم عن تنفيذ طلبك',
|
||||
];
|
||||
if (isset($titles[$status])) {
|
||||
foodSendNotificationToPassenger((string)$order['passenger_id'], $titles[$status], '', ['type' => 'food_order_' . $status, 'order_id' => (string)$orderId]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── نداء HTTP داخلي لـ socket_food — نفس نمط broadcast_bus_location في transit ──
|
||||
function foodPushToSocket(string $action, array $payload): void
|
||||
{
|
||||
$url = getenv('FOOD_SOCKET_URL') ?: 'http://socket_food:4041';
|
||||
$key = getInternalSocketKey();
|
||||
if (!$key) { appLog('[FOOD][SOCKET] INTERNAL_SOCKET_KEY missing — skip push', 'WARNING'); return; }
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query(['action' => $action, 'payload' => json_encode($payload)]),
|
||||
CURLOPT_HTTPHEADER => ["X-Internal-Key: $key"],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 1,
|
||||
]);
|
||||
$result = curl_exec($ch);
|
||||
if ($result === false) {
|
||||
appLog('[FOOD][SOCKET] push failed: ' . curl_error($ch), 'WARNING');
|
||||
}
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
function foodSendNotificationToPassenger(string $passengerId, string $title, string $body, array $data = []): void
|
||||
{
|
||||
global $redis;
|
||||
if (!$passengerId) return;
|
||||
$fcm = new FcmService($redis);
|
||||
$result = $fcm->sendToTopic('passenger_' . $passengerId, $title, $body, $data);
|
||||
if ($result['status'] !== 'success') {
|
||||
appLog("[FOOD][FCM] push failed for passenger {$passengerId}: " . json_encode($result), 'WARNING');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// قناة اتصال مقنّعة بين السائق والزبون (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
|
||||
{
|
||||
global $redis;
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$hash = hash('sha256', $token);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + 86400);
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
|
||||
$con = Database::get('food');
|
||||
$con->prepare(
|
||||
"INSERT INTO food_merchant_sessions (merchant_user_id, merchant_id, token_hash, ip, user_agent, expires_at)
|
||||
VALUES (?,?,?,?,?,?)"
|
||||
)->execute([$merchantUserId, $merchantId, $hash, $ip, $ua, $expiresAt]);
|
||||
|
||||
if ($redis) {
|
||||
$redis->setEx(
|
||||
"food:merchant_session:{$hash}",
|
||||
86400,
|
||||
json_encode(['merchant_user_id' => $merchantUserId, 'merchant_id' => $merchantId])
|
||||
);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
function foodAuthMerchant(): array
|
||||
{
|
||||
global $redis;
|
||||
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_FOOD_MERCHANT_TOKEN'] ?? '';
|
||||
$token = str_replace('Bearer ', '', $header);
|
||||
if (!$token) jsonError('Missing merchant session token', 401);
|
||||
|
||||
$hash = hash('sha256', $token);
|
||||
|
||||
if ($redis) {
|
||||
$val = $redis->get("food:merchant_session:{$hash}");
|
||||
if ($val) {
|
||||
$data = json_decode($val, true);
|
||||
if ($data) return $data;
|
||||
}
|
||||
jsonError('Session expired or invalid', 401);
|
||||
}
|
||||
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare(
|
||||
"SELECT merchant_user_id, merchant_id FROM food_merchant_sessions
|
||||
WHERE token_hash=? AND expires_at > NOW() LIMIT 1"
|
||||
);
|
||||
$st->execute([$hash]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) jsonError('Session expired or invalid', 401);
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ── هل المطعم مفتوح الآن؟ يحترم الإغلاق/الفتح اليدوي أولاً، ثم working_hours ──
|
||||
function foodIsMerchantOpen(array $merchant): bool
|
||||
{
|
||||
if (isset($merchant['is_open_override']) && $merchant['is_open_override'] !== null) {
|
||||
return (bool)$merchant['is_open_override'];
|
||||
}
|
||||
|
||||
$hours = $merchant['working_hours'] ?? null;
|
||||
if (!$hours) return true; // بلا جدول محدد = مفتوح افتراضياً
|
||||
|
||||
$hours = is_string($hours) ? json_decode($hours, true) : $hours;
|
||||
if (!$hours) return true;
|
||||
|
||||
$dayKeys = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
$today = $dayKeys[(int)date('w')];
|
||||
$ranges = $hours[$today] ?? [];
|
||||
if (!$ranges) return false;
|
||||
|
||||
$now = date('H:i');
|
||||
foreach ($ranges as $range) {
|
||||
if (!isset($range[0], $range[1])) continue;
|
||||
if ($now >= $range[0] && $now <= $range[1]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// المحفظة — S2S عبر سيرفر المحفظة القُطري (نفس عقد backend/api/payments/initiate_prime.php)
|
||||
//
|
||||
// ⚠️ ملاحظة صريحة: سيرفر المحفظة الفعلي لا يعرض API حجز-ثم-التقاط (hold/capture)
|
||||
// حقيقياً — العقد المتاح هو خصم/إضافة فوري فقط (action=subtract|add). لذلك
|
||||
// "الحجز" هنا هو **خصم فوري عند الإنشاء + استرجاع كامل عند الرفض/الإلغاء**،
|
||||
// وليس حجزاً بالمعنى المصرفي. إن أُضيف hold حقيقي لاحقاً في سيرفر المحفظة
|
||||
// فهذه الدالة أول مكان يُعدَّل.
|
||||
//
|
||||
// ⚠️ ملاحظة ثانية: عامل تحويل "أصغر وحدة نقدية" (fils/qirsh) إلى المبلغ
|
||||
// العشري الذي يتوقعه سيرفر المحفظة (كما في initiate_prime.php: 3.00 JOD)
|
||||
// غير مؤكد لكل دولة — FOOD_CURRENCY_DIVISOR افتراضي 1000 (مثل JOD/fils).
|
||||
// يجب تأكيده مع فريق المحفظة قبل أي تشغيل فعلي بمال حقيقي.
|
||||
// ============================================================
|
||||
|
||||
function foodWalletServerUrl(): string
|
||||
{
|
||||
// bootstrap.php يعرّف الثابت GLOBAL_COUNTRY فقط (لا putenv) — استخدم الثابت لا getenv
|
||||
$country = strtolower(defined('GLOBAL_COUNTRY') ? GLOBAL_COUNTRY : (getenv('GLOBAL_COUNTRY') ?: 'jordan'));
|
||||
return match ($country) {
|
||||
'egypt' => getenv('WALLET_SERVER_EGYPT') ?: 'https://wallet-egypt.siromove.com',
|
||||
'syria' => getenv('WALLET_SERVER_SYRIA') ?: 'https://wallet-syria.siromove.com',
|
||||
default => getenv('WALLET_SERVER_JORDAN') ?: 'https://walletintaleq.intaleq.xyz',
|
||||
};
|
||||
}
|
||||
|
||||
function foodSmallestUnitToDecimal(int $amount): float
|
||||
{
|
||||
$divisor = (float)(getenv('FOOD_CURRENCY_DIVISOR') ?: 1000);
|
||||
return round($amount / $divisor, 3);
|
||||
}
|
||||
|
||||
function foodWalletGetBalance(string $userId, string $userType = 'passenger'): ?float
|
||||
{
|
||||
$s2sKey = getenv('S2S_SHARED_KEY');
|
||||
if (!$s2sKey) { appLog('[FOOD][WALLET] S2S_SHARED_KEY missing', 'ERROR'); return null; }
|
||||
|
||||
$url = foodWalletServerUrl() . '/v2/main/ride/passengerWallet/getWalletByPassenger.php';
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query(['passenger_id' => $userId]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', "X-S2S-Api-Key: $s2sKey"],
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (!$raw || $code !== 200) { appLog("[FOOD][WALLET] balance fetch failed HTTP $code", 'ERROR'); return null; }
|
||||
$data = json_decode($raw, true);
|
||||
$bal = $data['message'][0]['total'] ?? $data['total'] ?? null;
|
||||
return $bal !== null ? (float)$bal : null;
|
||||
}
|
||||
|
||||
// $amountSmallestUnit موجب دائماً؛ $action = subtract (خصم) أو add (إضافة/استرجاع)
|
||||
function foodWalletMove(string $userId, int $amountSmallestUnit, string $action, string $paymentId, string $reason, string $userType = 'passenger'): bool
|
||||
{
|
||||
$s2sKey = getenv('S2S_SHARED_KEY');
|
||||
if (!$s2sKey) { appLog('[FOOD][WALLET] S2S_SHARED_KEY missing', 'ERROR'); return false; }
|
||||
|
||||
$decimalAmount = foodSmallestUnitToDecimal($amountSmallestUnit);
|
||||
$signedAmount = $action === 'subtract' ? -1 * $decimalAmount : $decimalAmount;
|
||||
|
||||
$url = foodWalletServerUrl() . '/v2/main/ride/payment/add.php';
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'user_id' => $userId,
|
||||
'user_type' => $userType,
|
||||
'amount' => $signedAmount,
|
||||
'action' => $action,
|
||||
'paymentID' => $paymentId,
|
||||
'paymentMethod' => 'food-order',
|
||||
'reason' => $reason,
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', "X-S2S-Api-Key: $s2sKey"],
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err || $code !== 200) {
|
||||
appLog("[FOOD][WALLET] move failed user=$userId action=$action HTTP $code err=$err", 'ERROR');
|
||||
return false;
|
||||
}
|
||||
$res = json_decode($raw, true);
|
||||
$ok = ($res['status'] ?? '') === 'success';
|
||||
if (!$ok) appLog("[FOOD][WALLET] move rejected by wallet server: $raw", 'ERROR');
|
||||
return $ok;
|
||||
}
|
||||
|
||||
// ── توقيع عرض السعر (السلة) — يثبّت المجموع 10 دقائق، الخادم لا يثق بسعر العميل ──
|
||||
function foodSignQuote(array $quote): string
|
||||
{
|
||||
$secret = getenv('SECRET_KEY_HMAC') ?: '';
|
||||
$quote['expires_at'] = time() + 600;
|
||||
$payload = base64_encode(json_encode($quote));
|
||||
$sig = hash_hmac('sha256', $payload, $secret);
|
||||
return $payload . '.' . $sig;
|
||||
}
|
||||
|
||||
function foodVerifyQuote(string $token): array
|
||||
{
|
||||
$secret = getenv('SECRET_KEY_HMAC') ?: '';
|
||||
$parts = explode('.', $token, 2);
|
||||
if (count($parts) !== 2) jsonError('Invalid quote token', 400);
|
||||
[$payload, $sig] = $parts;
|
||||
|
||||
$expected = hash_hmac('sha256', $payload, $secret);
|
||||
if (!hash_equals($expected, $sig)) jsonError('Quote token signature mismatch', 400);
|
||||
|
||||
$quote = json_decode(base64_decode($payload), true);
|
||||
if (!$quote) jsonError('Invalid quote token', 400);
|
||||
if (($quote['expires_at'] ?? 0) < time()) jsonError('Quote expired — refresh your cart', 409);
|
||||
|
||||
return $quote;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// أسطول التوصيل — نفس السائقين مع تمييز اختياري بالدور (can_deliver)
|
||||
//
|
||||
// ⚠️ لا نكتب على أي مفتاح Redis يديره loction_server/driver_socket.php —
|
||||
// التعديل على تلك العملية الدائمة الحية خارج نطاق هذه الوحدة ومخاطرته
|
||||
// عالية (تخدم كل مطابقة الرحلات). بدلاً من ذلك: سائق يفعّل "وضع التوصيل"
|
||||
// من تطبيقه فيُضاف إلى SET مستقلة `food:couriers:opted_in`، ونتقاطع مع
|
||||
// `geo:drivers:available` (يقرأها هذا الملف فقط — لا يكتب عليها أبداً)
|
||||
// لإيجاد سائقين متاحين للرحلات فعلياً وأيضاً منضمّين لوضع التوصيل.
|
||||
// ============================================================
|
||||
|
||||
function foodCourierOptIn(string $courierId, bool $enable): void
|
||||
{
|
||||
global $redis;
|
||||
if (!$redis) return;
|
||||
if ($enable) {
|
||||
$redis->sAdd('food:couriers:opted_in', $courierId);
|
||||
} else {
|
||||
$redis->sRem('food:couriers:opted_in', $courierId);
|
||||
}
|
||||
}
|
||||
|
||||
function foodFindNearbyCouriers(float $lat, float $lng, float $radiusKm = 5, int $limit = 10): array
|
||||
{
|
||||
global $redisLocation, $redis;
|
||||
if (!$redisLocation || !$redis) return [];
|
||||
|
||||
$nearby = $redisLocation->georadius(
|
||||
'geo:drivers:available', $lng, $lat, $radiusKm, 'km', ['COUNT' => $limit, 'SORT' => 'ASC']
|
||||
);
|
||||
if (!$nearby) return [];
|
||||
|
||||
$optedIn = $redis->sMembers('food:couriers:opted_in');
|
||||
if (!$optedIn) return [];
|
||||
|
||||
$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
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$con->prepare(
|
||||
"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/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 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول)
|
||||
function foodLockOrderForCourier(int $orderId, string $courierId): bool
|
||||
{
|
||||
global $redis;
|
||||
if (!$redis) return false;
|
||||
return (bool)$redis->set("food:order:{$orderId}:lock", $courierId, ['NX', 'EX' => 20]);
|
||||
}
|
||||
|
||||
function foodOrderLockOwner(int $orderId): ?string
|
||||
{
|
||||
global $redis;
|
||||
if (!$redis) return null;
|
||||
$v = $redis->get("food:order:{$orderId}:lock");
|
||||
return $v ?: null;
|
||||
}
|
||||
|
||||
// ── حساب مبالغ الطلب من الخادم — العميل لا يُملي السعر أبداً ──
|
||||
function foodComputeItemsTotal(array $lines): int
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($lines as $line) {
|
||||
$total += (int)$line['line_total'];
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
function foodComputeCommission(int $itemsTotal, float $commissionPercent): int
|
||||
{
|
||||
return (int) round($itemsTotal * $commissionPercent / 100);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// food/merchant/browse.php — تصفح المطاعم النشطة في مدينة الزبون
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$city = filterRequest('city');
|
||||
if (!$city) jsonError('city is required');
|
||||
|
||||
$category = filterRequest('category');
|
||||
|
||||
$sql = "SELECT id, name_ar, name_en, logo_url, cover_url, city, category,
|
||||
min_order_amount, avg_prep_minutes, rating_avg, rating_count,
|
||||
is_open_override, working_hours
|
||||
FROM food_merchants WHERE city=? AND status='active'";
|
||||
$params = [$city];
|
||||
|
||||
if ($category) {
|
||||
$sql .= " AND category=?";
|
||||
$params[] = $category;
|
||||
}
|
||||
$sql .= " ORDER BY rating_avg DESC, rating_count DESC";
|
||||
|
||||
$st = $food_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
$merchants = $st->fetchAll();
|
||||
|
||||
foreach ($merchants as &$m) {
|
||||
$m['is_open'] = foodIsMerchantOpen($m);
|
||||
unset($m['working_hours'], $m['is_open_override']);
|
||||
}
|
||||
unset($m);
|
||||
|
||||
jsonSuccess(['merchants' => $merchants]);
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
// food/merchant/details.php — تفاصيل مطعم واحد + قائمته الكاملة
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$merchantId = filterRequest('merchant_id', 'int');
|
||||
if (!$merchantId) jsonError('merchant_id is required');
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT id, name_ar, name_en, logo_url, cover_url, description_ar, city, address,
|
||||
latitude, longitude, category, min_order_amount, avg_prep_minutes,
|
||||
rating_avg, rating_count, is_open_override, working_hours
|
||||
FROM food_merchants WHERE id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$st->execute([$merchantId]);
|
||||
$merchant = $st->fetch();
|
||||
if (!$merchant) jsonError('Merchant not found', 404);
|
||||
|
||||
$merchant['is_open'] = foodIsMerchantOpen($merchant);
|
||||
unset($merchant['working_hours'], $merchant['is_open_override']);
|
||||
|
||||
$catSt = $food_con->prepare(
|
||||
"SELECT id, name_ar, name_en, sort_order FROM food_menu_categories
|
||||
WHERE merchant_id=? AND is_active=1 ORDER BY sort_order ASC"
|
||||
);
|
||||
$catSt->execute([$merchantId]);
|
||||
$categories = $catSt->fetchAll();
|
||||
|
||||
$itemSt = $food_con->prepare(
|
||||
"SELECT id, category_id, name_ar, name_en, description_ar, image_url, price,
|
||||
is_available, prep_minutes, sort_order
|
||||
FROM food_menu_items WHERE merchant_id=? ORDER BY sort_order ASC"
|
||||
);
|
||||
$itemSt->execute([$merchantId]);
|
||||
$items = $itemSt->fetchAll();
|
||||
|
||||
if ($items) {
|
||||
$itemIds = implode(',', array_map('intval', array_column($items, 'id')));
|
||||
$optSt = $food_con->query(
|
||||
"SELECT id, item_id, group_name_ar, is_required, max_select, choices, sort_order
|
||||
FROM food_item_options WHERE item_id IN ($itemIds) ORDER BY sort_order ASC"
|
||||
);
|
||||
$options = $optSt ? $optSt->fetchAll() : [];
|
||||
$byItem = [];
|
||||
foreach ($options as $o) {
|
||||
$o['choices'] = json_decode($o['choices'], true);
|
||||
$byItem[$o['item_id']][] = $o;
|
||||
}
|
||||
foreach ($items as &$it) {
|
||||
$it['options'] = $byItem[$it['id']] ?? [];
|
||||
}
|
||||
unset($it);
|
||||
}
|
||||
|
||||
foreach ($categories as &$c) {
|
||||
$c['items'] = array_values(array_filter($items, fn($i) => (int)$i['category_id'] === (int)$c['id']));
|
||||
}
|
||||
unset($c);
|
||||
|
||||
jsonSuccess(['merchant' => $merchant, 'categories' => $categories]);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// food/merchant/search.php — بحث نصي في اسم المطعم أو أصنافه ضمن مدينة الزبون
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$city = filterRequest('city');
|
||||
$q = filterRequest('q');
|
||||
if (!$city) jsonError('city is required');
|
||||
if (!$q || mb_strlen($q) < 2) jsonError('q must be at least 2 characters');
|
||||
|
||||
$like = '%' . $q . '%';
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT DISTINCT m.id, m.name_ar, m.name_en, m.logo_url, m.city, m.category,
|
||||
m.rating_avg, m.rating_count
|
||||
FROM food_merchants m
|
||||
LEFT JOIN food_menu_items i ON i.merchant_id = m.id
|
||||
WHERE m.city = ? AND m.status='active'
|
||||
AND (m.name_ar LIKE ? OR m.name_en LIKE ? OR i.name_ar LIKE ? OR i.name_en LIKE ?)
|
||||
ORDER BY m.rating_avg DESC
|
||||
LIMIT 50"
|
||||
);
|
||||
$st->execute([$city, $like, $like, $like, $like]);
|
||||
|
||||
jsonSuccess(['merchants' => $st->fetchAll()]);
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
// food/merchant_auth/login.php — دخول لوحة المطعم (هاتف + كلمة مرور)
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'login');
|
||||
|
||||
requireFoodFields(['phone', 'password']);
|
||||
$phone = normalizePhone(filterRequest('phone'));
|
||||
$password = filterRequest('password');
|
||||
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
jsonError('Food service unavailable', 503);
|
||||
}
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT id, merchant_id, password_hash, is_active FROM food_merchant_users WHERE phone=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$phone]);
|
||||
$user = $st->fetch();
|
||||
|
||||
if (!$user || !$user['is_active'] || !password_verify($password, $user['password_hash'])) {
|
||||
appLog("[FOOD][MERCHANT_LOGIN] failed attempt for phone hash " . hash('sha256', $phone), 'WARNING');
|
||||
jsonError('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
$merchantSt = $food_con->prepare("SELECT status FROM food_merchants WHERE id=?");
|
||||
$merchantSt->execute([$user['merchant_id']]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
if (!$merchant || !in_array($merchant['status'], ['active', 'paused'], true)) {
|
||||
jsonError('Merchant account is not active', 403);
|
||||
}
|
||||
|
||||
$token = foodCreateMerchantSession((int)$user['id'], (int)$user['merchant_id']);
|
||||
|
||||
jsonSuccess(['session_token' => $token, 'merchant_id' => (int)$user['merchant_id']], 'Login successful');
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// food/merchant_ops/accept.php — المطعم يقبل الطلب
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
|
||||
food_transition_status($orderId, 'merchant_accepted', 'merchant', (string)$food_merchant_user_id);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'merchant_accepted']);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// food/merchant_ops/incoming.php — الطلبات الحالية للمطعم
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$statusFilter = filterRequest('status') ?: 'active';
|
||||
$allowed = ['active', 'pending', 'merchant_accepted', 'preparing', 'ready', 'history'];
|
||||
if (!in_array($statusFilter, $allowed, true)) $statusFilter = 'active';
|
||||
|
||||
if ($statusFilter === 'active') {
|
||||
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at
|
||||
FROM food_orders WHERE merchant_id=? AND status IN ('pending','merchant_accepted','preparing','ready','courier_assigned','picked_up')
|
||||
ORDER BY created_at ASC";
|
||||
$params = [$food_merchant_id];
|
||||
} elseif ($statusFilter === 'history') {
|
||||
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at, delivered_at
|
||||
FROM food_orders WHERE merchant_id=? AND status IN ('delivered','rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system')
|
||||
ORDER BY created_at DESC LIMIT 100";
|
||||
$params = [$food_merchant_id];
|
||||
} else {
|
||||
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at
|
||||
FROM food_orders WHERE merchant_id=? AND status=? ORDER BY created_at ASC";
|
||||
$params = [$food_merchant_id, $statusFilter];
|
||||
}
|
||||
|
||||
$st = $food_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
$orders = $st->fetchAll();
|
||||
|
||||
if ($orders) {
|
||||
$ids = implode(',', array_map('intval', array_column($orders, 'id')));
|
||||
$itemsSt = $food_con->query("SELECT order_id, name_ar_snapshot, quantity FROM food_order_items WHERE order_id IN ($ids)");
|
||||
$allItems = $itemsSt ? $itemsSt->fetchAll() : [];
|
||||
foreach ($orders as &$o) {
|
||||
$o['items'] = array_values(array_filter($allItems, fn($i) => (int)$i['order_id'] === (int)$o['id']));
|
||||
}
|
||||
unset($o);
|
||||
}
|
||||
|
||||
jsonSuccess(['orders' => $orders]);
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
// food/merchant_ops/items_toggle.php — تفعيل/إيقاف صنف (نفد من المخزون مثلاً)
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$itemId = filterRequest('item_id', 'int');
|
||||
if (!$itemId) jsonError('item_id is required');
|
||||
|
||||
$st = $food_con->prepare("SELECT id, merchant_id, is_available FROM food_menu_items WHERE id=? LIMIT 1");
|
||||
$st->execute([$itemId]);
|
||||
$item = $st->fetch();
|
||||
if (!$item) jsonError('Item not found', 404);
|
||||
if ((int)$item['merchant_id'] !== $food_merchant_id) jsonError('Forbidden', 403);
|
||||
|
||||
$newAvailability = $item['is_available'] ? 0 : 1;
|
||||
$food_con->prepare("UPDATE food_menu_items SET is_available=? WHERE id=?")->execute([$newAvailability, $itemId]);
|
||||
|
||||
jsonSuccess(['item_id' => $itemId, 'is_available' => (bool)$newAvailability]);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// food/merchant_ops/preparing.php — المطعم بدأ التحضير
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
|
||||
food_transition_status($orderId, 'preparing', 'merchant', (string)$food_merchant_user_id);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'preparing']);
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// food/merchant_ops/ready.php — الطلب جاهز — يبدأ حلقة إسناد السائق
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
|
||||
food_transition_status($orderId, 'ready', 'merchant', (string)$food_merchant_user_id);
|
||||
|
||||
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
|
||||
$merchantSt->execute([$food_merchant_id]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
|
||||
$offered = false;
|
||||
if ($merchant) {
|
||||
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
|
||||
|
||||
// استبعاد من سبق عرض هذا الطلب عليه (رفض أو انتهت مهلته)
|
||||
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
|
||||
$priorSt->execute([$orderId]);
|
||||
$prior = array_column($priorSt->fetchAll(), 'courier_id');
|
||||
|
||||
foreach ($candidates as $courierId) {
|
||||
if (in_array($courierId, $prior, true)) continue;
|
||||
foodOfferOrderToCourier($orderId, $courierId);
|
||||
$offered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$offered) {
|
||||
appLog("[FOOD][DELIVERY] No available courier found for order $orderId at ready-time", 'WARNING');
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'ready', 'courier_offer_sent' => $offered]);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// food/merchant_ops/reject.php — المطعم يرفض الطلب — استرجاع فوري إن دُفع من المحفظة
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$reason = filterRequest('reason');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
|
||||
food_transition_status($orderId, 'rejected', 'merchant', (string)$food_merchant_user_id, $reason);
|
||||
|
||||
if ($order['payment_method'] === 'wallet') {
|
||||
$refunded = foodWalletMove(
|
||||
(string)$order['passenger_id'], (int)$order['grand_total'], 'add',
|
||||
"food-refund-{$orderId}", "Food order #{$orderId} rejected by merchant"
|
||||
);
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
|
||||
)->execute([$orderId, (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
|
||||
|
||||
if (!$refunded) appLog("[FOOD][MERCHANT][reject] refund FAILED for order $orderId — needs manual reconciliation", 'ERROR');
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'rejected']);
|
||||
@@ -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,28 @@
|
||||
<?php
|
||||
// food/order/cancel.php — إلغاء الزبون قبل قبول المطعم فقط (بعده يتطلب اتصالاً بالمطعم)
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$reason = filterRequest('reason');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||
if ($order['status'] !== 'pending') {
|
||||
jsonError('Order can no longer be cancelled directly — it has already been accepted by the merchant', 409);
|
||||
}
|
||||
|
||||
food_transition_status($orderId, 'cancelled_by_customer', 'customer', $food_passenger_id, $reason);
|
||||
|
||||
if ($order['payment_method'] === 'wallet') {
|
||||
$refunded = foodWalletMove(
|
||||
$food_passenger_id, (int)$order['grand_total'], 'add',
|
||||
"food-refund-{$orderId}", "Food order #{$orderId} cancelled"
|
||||
);
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
|
||||
)->execute([$orderId, (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
|
||||
|
||||
if (!$refunded) appLog("[FOOD][ORDER][cancel] refund FAILED for order $orderId — needs manual reconciliation", 'ERROR');
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'cancelled_by_customer']);
|
||||
@@ -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,113 @@
|
||||
<?php
|
||||
// food/order/create.php — إنشاء الطلب من عرض سعر موقّع + خصم فوري من المحفظة
|
||||
// body: quote_token, client_order_uuid, delivery_address, delivery_lat, delivery_lng,
|
||||
// payment_method (wallet|cash), customer_note
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
requireFoodFields(['quote_token', 'client_order_uuid', 'delivery_address', 'delivery_lat', 'delivery_lng']);
|
||||
|
||||
$quoteToken = filterRequest('quote_token');
|
||||
$clientUuid = filterRequest('client_order_uuid');
|
||||
$address = filterRequest('delivery_address');
|
||||
$lat = filterRequest('delivery_lat', 'float');
|
||||
$lng = filterRequest('delivery_lng', 'float');
|
||||
$paymentMethod = filterRequest('payment_method') ?: 'wallet';
|
||||
$note = filterRequest('customer_note');
|
||||
|
||||
if (!in_array($paymentMethod, ['wallet', 'cash'], true)) jsonError('Invalid payment_method');
|
||||
if (!preg_match('/^[0-9a-fA-F-]{36}$/', $clientUuid)) jsonError('client_order_uuid must be a valid UUID');
|
||||
|
||||
$quote = foodVerifyQuote($quoteToken);
|
||||
if ((string)$quote['passenger_id'] !== $food_passenger_id) jsonError('Quote does not belong to this session', 403);
|
||||
|
||||
// idempotency — الضغط المزدوج أو إعادة محاولة الشبكة يرجع نفس الطلب لا طلباً جديداً
|
||||
$dupSt = $food_con->prepare("SELECT id FROM food_orders WHERE client_order_uuid=? LIMIT 1");
|
||||
$dupSt->execute([$clientUuid]);
|
||||
if ($existing = $dupSt->fetch()) {
|
||||
jsonSuccess(['order_id' => (int)$existing['id'], 'idempotent_replay' => true], 'Order already exists');
|
||||
}
|
||||
|
||||
$maxActive = (int)(getenv('FOOD_MAX_ACTIVE_ORDERS_PER_USER') ?: 3);
|
||||
$activeSt = $food_con->prepare(
|
||||
"SELECT COUNT(*) c FROM food_orders WHERE passenger_id=? AND status NOT IN
|
||||
('delivered','rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system')"
|
||||
);
|
||||
$activeSt->execute([$food_passenger_id]);
|
||||
if ((int)$activeSt->fetch()['c'] >= $maxActive) {
|
||||
jsonError("You already have $maxActive active orders — finish or cancel one first", 409);
|
||||
}
|
||||
|
||||
$merchantSt = $food_con->prepare("SELECT id, status, commission_percent FROM food_merchants WHERE id=? LIMIT 1");
|
||||
$merchantSt->execute([$quote['merchant_id']]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
if (!$merchant || $merchant['status'] !== 'active') jsonError('Merchant is no longer available', 409);
|
||||
|
||||
$itemsTotal = (int)$quote['items_total'];
|
||||
$deliveryFee = (int)$quote['delivery_fee'];
|
||||
$serviceFee = (int)$quote['service_fee'];
|
||||
$grandTotal = (int)$quote['grand_total'];
|
||||
$commission = foodComputeCommission($itemsTotal, (float)$merchant['commission_percent']);
|
||||
|
||||
if ($paymentMethod === 'wallet') {
|
||||
$balance = foodWalletGetBalance($food_passenger_id);
|
||||
if ($balance === null) jsonError('Unable to verify wallet balance. Please try again.', 503);
|
||||
if ($balance < foodSmallestUnitToDecimal($grandTotal)) {
|
||||
jsonError('Insufficient wallet balance', 402, ['current_balance' => $balance]);
|
||||
}
|
||||
}
|
||||
|
||||
$food_con->beginTransaction();
|
||||
try {
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_orders
|
||||
(client_order_uuid, passenger_id, merchant_id, status, items_total, delivery_fee, service_fee,
|
||||
discount, grand_total, commission_amount, payment_method, delivery_address, delivery_lat,
|
||||
delivery_lng, customer_note)
|
||||
VALUES (?,?,?,'pending',?,?,?,0,?,?,?,?,?,?,?)"
|
||||
)->execute([
|
||||
$clientUuid, $food_passenger_id, $quote['merchant_id'], $itemsTotal, $deliveryFee, $serviceFee,
|
||||
$grandTotal, $commission, $paymentMethod, $address, $lat, $lng, $note,
|
||||
]);
|
||||
$orderId = (int)$food_con->lastInsertId();
|
||||
|
||||
$insertLine = $food_con->prepare(
|
||||
"INSERT INTO food_order_items (order_id, item_id, name_ar_snapshot, unit_price, quantity, option_price_json, line_total)
|
||||
VALUES (?,?,?,?,?,?,?)"
|
||||
);
|
||||
foreach ($quote['lines'] as $line) {
|
||||
$insertLine->execute([
|
||||
$orderId, $line['item_id'], $line['name_ar_snapshot'], $line['unit_price'],
|
||||
$line['quantity'], json_encode($line['options']), $line['line_total'],
|
||||
]);
|
||||
}
|
||||
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_status_log (order_id, from_status, to_status, actor_type, actor_id)
|
||||
VALUES (?,NULL,'pending','customer',?)"
|
||||
)->execute([$orderId, $food_passenger_id]);
|
||||
|
||||
$food_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$food_con->rollBack();
|
||||
appLog('[FOOD][ORDER][create] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to create order', 500);
|
||||
}
|
||||
|
||||
if ($paymentMethod === 'wallet') {
|
||||
$debited = foodWalletMove($food_passenger_id, $grandTotal, 'subtract', "food-order-{$orderId}", "Food order #{$orderId}");
|
||||
if (!$debited) {
|
||||
// فشل الخصم بعد إنشاء السجل — نُلغي الطلب فوراً بدل ترك طلب بلا دفع
|
||||
food_transition_status($orderId, 'cancelled_system', 'system', 'wallet', 'Wallet debit failed');
|
||||
jsonError('Wallet payment failed — order cancelled', 402);
|
||||
}
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'hold',?,'success')"
|
||||
)->execute([$orderId, $grandTotal]);
|
||||
}
|
||||
|
||||
foodPushToSocket('order_status_update', [
|
||||
'order_id' => $orderId, 'status' => 'pending', 'passenger_id' => $food_passenger_id,
|
||||
'merchant_id' => (int)$quote['merchant_id'], 'courier_id' => null,
|
||||
]);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'pending', 'grand_total' => $grandTotal], 'Order placed');
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
// food/order/history.php — سجل طلبات الزبون
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
|
||||
$limit = 20;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.id, o.status, o.grand_total, o.created_at, o.delivered_at, o.rating,
|
||||
m.name_ar AS merchant_name_ar, m.logo_url AS merchant_logo_url
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.passenger_id = ?
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT $limit OFFSET $offset"
|
||||
);
|
||||
$st->execute([$food_passenger_id]);
|
||||
|
||||
jsonSuccess(['orders' => $st->fetchAll(), 'page' => $page]);
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// food/order/rate.php — تقييم الطلب بعد التسليم (يحدّث متوسط تقييم المطعم)
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$rating = filterRequest('rating', 'int');
|
||||
$comment = filterRequest('comment');
|
||||
|
||||
if (!$orderId || !$rating || $rating < 1 || $rating > 5) jsonError('order_id and rating (1-5) are required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||
if ($order['status'] !== 'delivered') jsonError('Only delivered orders can be rated', 409);
|
||||
if ($order['rating'] !== null) jsonError('Order already rated', 409);
|
||||
|
||||
$food_con->beginTransaction();
|
||||
try {
|
||||
$food_con->prepare("UPDATE food_orders SET rating=?, rating_comment=? WHERE id=?")
|
||||
->execute([$rating, $comment, $orderId]);
|
||||
|
||||
$food_con->prepare(
|
||||
"UPDATE food_merchants SET
|
||||
rating_avg = ((rating_avg * rating_count) + ?) / (rating_count + 1),
|
||||
rating_count = rating_count + 1
|
||||
WHERE id=?"
|
||||
)->execute([$rating, $order['merchant_id']]);
|
||||
|
||||
$food_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$food_con->rollBack();
|
||||
appLog('[FOOD][ORDER][rate] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to save rating', 500);
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'rating' => $rating]);
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
// food/order/status.php — حالة الطلب الحالية (مصدر الحقيقة عند إعادة الاتصال بالسوكيت)
|
||||
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);
|
||||
|
||||
$itemsSt = $food_con->prepare("SELECT name_ar_snapshot, unit_price, quantity, line_total FROM food_order_items WHERE order_id=?");
|
||||
$itemsSt->execute([$orderId]);
|
||||
$order['items'] = $itemsSt->fetchAll();
|
||||
|
||||
// عناوين/أرقام الزبون تُحجب عن السائق قبل courier_assigned وبعد delivered — هنا هي بوابة الزبون نفسه فلا حجب
|
||||
jsonSuccess(['order' => $order]);
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
// food/ping.php — فحص الأساس: الحاوية php_food + قاعدة siro_food تعملان
|
||||
require_once __DIR__ . '/connect_app.php';
|
||||
|
||||
$food_con->query('SELECT 1');
|
||||
jsonSuccess(['service' => 'food', 'db' => 'ok'], 'pong');
|
||||
@@ -0,0 +1,289 @@
|
||||
-- =============================================================
|
||||
-- schema_food.sql — قاعدة بيانات وحدة طلبات الطعام (siro_food)
|
||||
-- عزل كامل عن main/ride/transit — ممنوع أي JOIN خارجي
|
||||
-- الربط بالنظام الرئيسي عبر passenger_id / courier_id (main driver id) فقط
|
||||
-- =============================================================
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 1. food_merchants — المطاعم/المتاجر
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_merchants` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name_ar` VARCHAR(150) NOT NULL,
|
||||
`name_en` VARCHAR(150) DEFAULT NULL,
|
||||
`logo_url` VARCHAR(500) DEFAULT NULL,
|
||||
`cover_url` VARCHAR(500) DEFAULT NULL,
|
||||
`description_ar` VARCHAR(500) DEFAULT NULL,
|
||||
`city` VARCHAR(80) NOT NULL,
|
||||
`address` VARCHAR(300) DEFAULT NULL,
|
||||
`latitude` DECIMAL(10,7) NOT NULL,
|
||||
`longitude` DECIMAL(10,7) NOT NULL,
|
||||
`category` VARCHAR(60) DEFAULT NULL COMMENT 'مطبخ عربي، بيتزا، حلويات...',
|
||||
`commission_percent` DECIMAL(5,2) NOT NULL DEFAULT 15.00,
|
||||
`min_order_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'أصغر وحدة نقدية',
|
||||
`avg_prep_minutes` SMALLINT UNSIGNED NOT NULL DEFAULT 20,
|
||||
`rating_avg` DECIMAL(3,2) NOT NULL DEFAULT 0.00,
|
||||
`rating_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`status` ENUM('pending_approval','active','paused','suspended','rejected') NOT NULL DEFAULT 'pending_approval',
|
||||
`is_open_override` TINYINT(1) DEFAULT NULL COMMENT 'NULL=يتبع working_hours، 0/1=إغلاق/فتح يدوي فوري',
|
||||
`working_hours` JSON DEFAULT NULL COMMENT '{"sun":[["09:00","23:00"]], ...}',
|
||||
`approved_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin id من النظام الرئيسي',
|
||||
`approved_at` TIMESTAMP DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_city_status` (`city`, `status`),
|
||||
KEY `idx_location` (`latitude`, `longitude`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 2. food_merchant_users — حسابات دخول أصحاب المطاعم (دور merchant، هوية منفصلة عن الراكب)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_merchant_users` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(120) NOT NULL,
|
||||
`phone` VARCHAR(25) NOT NULL COMMENT 'مشفّر بنفس EncryptionHelper',
|
||||
`role` ENUM('owner','staff') NOT NULL DEFAULT 'owner',
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_phone` (`phone`),
|
||||
KEY `idx_merchant` (`merchant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 3. food_menu_categories — أقسام قائمة المطعم
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_menu_categories` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`name_ar` VARCHAR(100) NOT NULL,
|
||||
`name_en` VARCHAR(100) DEFAULT NULL,
|
||||
`sort_order` SMALLINT NOT NULL DEFAULT 0,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_merchant_sort` (`merchant_id`, `sort_order`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 4. food_menu_items — أصناف القائمة
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_menu_items` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`category_id` INT UNSIGNED NOT NULL,
|
||||
`name_ar` VARCHAR(150) NOT NULL,
|
||||
`name_en` VARCHAR(150) DEFAULT NULL,
|
||||
`description_ar` VARCHAR(500) DEFAULT NULL,
|
||||
`image_url` VARCHAR(500) DEFAULT NULL,
|
||||
`price` BIGINT UNSIGNED NOT NULL COMMENT 'أصغر وحدة نقدية',
|
||||
`is_available` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`prep_minutes` SMALLINT UNSIGNED DEFAULT NULL COMMENT 'NULL = يستخدم avg_prep_minutes للمطعم',
|
||||
`sort_order` SMALLINT NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_merchant_available` (`merchant_id`, `is_available`),
|
||||
KEY `idx_category` (`category_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 5. food_item_options — مجموعات خيارات الصنف (حجم، إضافات...)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_item_options` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`item_id` INT UNSIGNED NOT NULL,
|
||||
`group_name_ar` VARCHAR(100) NOT NULL COMMENT 'مثال: الحجم، الإضافات',
|
||||
`is_required` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`max_select` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=اختيار واحد، أكثر=متعدد',
|
||||
`choices` JSON NOT NULL COMMENT '[{"id":"lg","label_ar":"كبير","price":500}]',
|
||||
`sort_order` SMALLINT NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_item` (`item_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 6. food_orders — الطلب
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_orders` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`client_order_uuid` CHAR(36) NOT NULL COMMENT 'idempotency — يمنع الضغط المزدوج من إنشاء طلبين',
|
||||
`passenger_id` VARCHAR(100) NOT NULL COMMENT 'مرجع منطقي فقط — لا FK خارجي',
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`courier_id` VARCHAR(100) DEFAULT NULL COMMENT 'main driver id بعد الإسناد',
|
||||
`status` ENUM(
|
||||
'pending','merchant_accepted','preparing','ready',
|
||||
'courier_assigned','picked_up','delivered',
|
||||
'rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system'
|
||||
) NOT NULL DEFAULT 'pending',
|
||||
`items_total` BIGINT UNSIGNED NOT NULL COMMENT 'مجموع أصناف الطلب — أصغر وحدة نقدية',
|
||||
`delivery_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`service_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`discount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`grand_total` BIGINT UNSIGNED NOT NULL COMMENT 'items_total+delivery_fee+service_fee-discount',
|
||||
`commission_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'نصيب المنصة من items_total',
|
||||
`payment_method` ENUM('wallet','cash') NOT NULL DEFAULT 'wallet',
|
||||
`payment_hold_ref` VARCHAR(100) DEFAULT NULL COMMENT 'مرجع حجز المحفظة في payment_server/v2',
|
||||
`delivery_address` VARCHAR(300) NOT NULL,
|
||||
`delivery_lat` DECIMAL(10,7) NOT NULL,
|
||||
`delivery_lng` DECIMAL(10,7) NOT NULL,
|
||||
`customer_note` VARCHAR(300) DEFAULT NULL,
|
||||
`promo_code` VARCHAR(40) DEFAULT NULL,
|
||||
`rating` TINYINT UNSIGNED DEFAULT NULL,
|
||||
`rating_comment` VARCHAR(300) DEFAULT NULL,
|
||||
`merchant_accepted_at` TIMESTAMP DEFAULT NULL,
|
||||
`ready_at` TIMESTAMP DEFAULT NULL,
|
||||
`courier_assigned_at` TIMESTAMP DEFAULT NULL,
|
||||
`picked_up_at` TIMESTAMP DEFAULT NULL,
|
||||
`delivered_at` TIMESTAMP DEFAULT NULL,
|
||||
`cancelled_at` TIMESTAMP DEFAULT NULL,
|
||||
`cancel_reason` VARCHAR(300) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_client_uuid` (`client_order_uuid`),
|
||||
KEY `idx_passenger_created` (`passenger_id`, `created_at`),
|
||||
KEY `idx_merchant_status` (`merchant_id`, `status`),
|
||||
KEY `idx_courier_status` (`courier_id`, `status`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 7. food_order_items — أصناف الطلب بسعر مجمّد وقت الطلب
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_order_items` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`order_id` INT UNSIGNED NOT NULL,
|
||||
`item_id` INT UNSIGNED NOT NULL COMMENT 'مرجع فقط — لا JOIN للسعر',
|
||||
`name_ar_snapshot` VARCHAR(150) NOT NULL,
|
||||
`unit_price` BIGINT UNSIGNED NOT NULL COMMENT 'سعر الوحدة وقت الطلب — مجمّد',
|
||||
`quantity` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`option_price_json` JSON DEFAULT NULL COMMENT 'الخيارات المختارة وأسعارها وقت الطلب',
|
||||
`line_total` BIGINT UNSIGNED NOT NULL COMMENT '(unit_price+مجموع الخيارات)×quantity',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order` (`order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 8. food_order_status_log — كل انتقال حالة — مصدر الحقيقة للنزاعات
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_order_status_log` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`order_id` INT UNSIGNED NOT NULL,
|
||||
`from_status` VARCHAR(30) DEFAULT NULL,
|
||||
`to_status` VARCHAR(30) NOT NULL,
|
||||
`actor_type` ENUM('customer','merchant','courier','admin','system') NOT NULL,
|
||||
`actor_id` VARCHAR(100) DEFAULT NULL,
|
||||
`note` VARCHAR(300) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order` (`order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 9. food_order_payments — مرجع معاملة المحفظة/الدفع + التسوية
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_order_payments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`order_id` INT UNSIGNED NOT NULL,
|
||||
`type` ENUM('hold','capture','release','cash_settlement','courier_payout') NOT NULL,
|
||||
`amount` BIGINT UNSIGNED NOT NULL,
|
||||
`wallet_ref` VARCHAR(100) DEFAULT NULL COMMENT 'مرجع من payment_server/v2',
|
||||
`status` ENUM('pending','success','failed') NOT NULL DEFAULT 'pending',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order` (`order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 10. food_courier_assignments — محاولات إسناد الطلب لسائق
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_courier_assignments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`order_id` INT UNSIGNED NOT NULL,
|
||||
`courier_id` VARCHAR(100) NOT NULL COMMENT 'main driver id',
|
||||
`status` ENUM('offered','accepted','rejected','timed_out') NOT NULL DEFAULT 'offered',
|
||||
`offered_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`responded_at` TIMESTAMP DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order` (`order_id`),
|
||||
KEY `idx_courier` (`courier_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 11. food_merchant_payouts — مستحقات المطاعم ودورات التسوية
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_merchant_payouts` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`period_start` DATE NOT NULL,
|
||||
`period_end` DATE NOT NULL,
|
||||
`orders_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`gross_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`commission_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`net_payout` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`status` ENUM('pending','paid') NOT NULL DEFAULT 'pending',
|
||||
`paid_at` TIMESTAMP DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_merchant_period` (`merchant_id`, `period_start`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 12. food_promo_codes — أكواد خصم خاصة بالطعام (منفصلة عن أكواد الرحلات)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_promo_codes` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`code` VARCHAR(40) NOT NULL,
|
||||
`discount_type` ENUM('percent','fixed') NOT NULL DEFAULT 'fixed',
|
||||
`discount_value` INT UNSIGNED NOT NULL,
|
||||
`max_discount` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`min_order_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`usage_limit` INT UNSIGNED DEFAULT NULL,
|
||||
`usage_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`valid_from` TIMESTAMP DEFAULT NULL,
|
||||
`valid_until` TIMESTAMP DEFAULT NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_code` (`code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 13. food_merchant_sessions — جلسات دخول لوحة المطعم (هاتف+كلمة مرور،
|
||||
-- session token مستقل عن JWT — نفس نمط transit_sessions، لأن لوحة
|
||||
-- المطعم ويب متجاوب لا يملك device fingerprint كتطبيق الجوال)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_merchant_sessions` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_user_id` INT UNSIGNED NOT NULL,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للـ session token',
|
||||
`ip` VARCHAR(45) DEFAULT NULL,
|
||||
`user_agent` VARCHAR(300) DEFAULT NULL,
|
||||
`expires_at` TIMESTAMP NOT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_token` (`token_hash`),
|
||||
KEY `idx_merchant_user` (`merchant_user_id`),
|
||||
KEY `idx_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- ملاحظات Redis (مفاتيح تُكتب من PHP — ليست في SQL):
|
||||
-- food:order:{id}:lock → SET NX EX 20 عند قبول سائق (يمنع سباق القبول)
|
||||
-- geo:couriers:available → مجموعة جغرافية للسائقين can_deliver=1 المتفرغين
|
||||
-- food:merchant:{id}:cache → كاش بيانات مطعم للقراءة السريعة (اختياري لاحقاً)
|
||||
-- Redis channels للسوكيت: food:order:{id} / food:merchant:{id} / food:courier:{id}
|
||||
-- -----------------------------------------------------------------
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user