Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-09 16:56:13 +03:00
parent 95e2e4f35d
commit b64debaa88
1058 changed files with 164327 additions and 113928 deletions
+231
View File
@@ -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;
}
+23
View File
@@ -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]);
+48
View File
@@ -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');
+22
View File
@@ -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()]);
+39
View File
@@ -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');
+110
View File
@@ -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,
]);
+30
View File
@@ -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;
}
+39
View File
@@ -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;
}
+36
View File
@@ -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;
}
+43
View File
@@ -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'];
+38
View File
@@ -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]);
+48
View File
@@ -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'],
]);
+40
View File
@@ -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']);
+57
View File
@@ -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()),
]);
+34
View File
@@ -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]);
+48
View File
@@ -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']]);
+76
View File
@@ -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']);
+64
View File
@@ -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]);
+29
View File
@@ -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]);
+11
View File
@@ -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']);
+32
View File
@@ -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]);
+65
View File
@@ -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";
+617
View File
@@ -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);
}
+32
View File
@@ -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]);
+59
View File
@@ -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]);
+24
View File
@@ -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()]);
+41
View File
@@ -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');
+11
View File
@@ -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']);
+39
View File
@@ -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]);
+11
View File
@@ -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']);
+36
View File
@@ -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]);
+24
View File
@@ -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';
+39
View File
@@ -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'],
]);
+28
View File
@@ -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']);
+49
View File
@@ -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,
]);
+113
View File
@@ -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');
+20
View File
@@ -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]);
+34
View File
@@ -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]);
+15
View File
@@ -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]);
+6
View File
@@ -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');
+289
View File
@@ -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}
-- -----------------------------------------------------------------