Files
Siro/backend/food/functions.php
T

473 lines
20 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');
}
}
// ── 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 [];
return array_values(array_intersect($nearby, $optedIn));
}
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]);
// ملاحظة: لا FCM هنا عمداً — توكن جهاز السائق في جدول driverToken على main DB،
// وممنوع Database::get('main') داخل backend/food/ (نفس قاعدة transit). الإشعار
// اللحظي يمر فقط عبر socket_food (السائق متصل بسوكيته أثناء وضع التوصيل)،
// والتطبيق يعتمد أيضاً على courier/active.php كـ polling fallback عند الانقطاع.
foodPushToSocket('courier_offer', ['order_id' => $orderId, 'courier_id' => $courierId]);
}
// 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);
}