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); }