618 lines
26 KiB
PHP
618 lines
26 KiB
PHP
<?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);
|
|
}
|