Update codebase
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/admin/courier_settlement.php — تسوية أرباح سائق التوصيل
|
||||
//
|
||||
// قرار المالك: الأجرة تتراكم وتُصرف **دورياً مع مقاصّة دَين النقد**.
|
||||
//
|
||||
// لماذا المقاصّة: في الطلب النقدي يحصّل السائق grand_total كاملاً من
|
||||
// الزبون، فيصير مديناً للمنصة بحصة المطعم والعمولة. صرف أجرته منفصلاً
|
||||
// عن هذا الدَين يعني أن تدفع له بينما هو يحمل مالك.
|
||||
//
|
||||
// الصافي = أجور التوصيل − ديون النقد − المُرحَّل من دورة سابقة
|
||||
//
|
||||
// عمليتان في نقطة واحدة:
|
||||
// action=preview (الافتراضي) — يحسب ويعرض بلا أي أثر
|
||||
// action=execute — يثبّت الدورة ويصرف إن كان الصافي موجباً
|
||||
//
|
||||
// الفصل مقصود: صرف المال يجب أن يسبقه اطّلاع، لا أن يكون أثراً جانبياً
|
||||
// لفتح شاشة.
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
require_once __DIR__ . '/../../ride/pricing/pricing_helper.php';
|
||||
|
||||
$courierId = filterRequest('courier_id');
|
||||
$periodStart = filterRequest('period_start');
|
||||
$periodEnd = filterRequest('period_end');
|
||||
$action = filterRequest('action') === 'execute' ? 'execute' : 'preview';
|
||||
|
||||
requireFoodFields(['courier_id', 'period_start', 'period_end']);
|
||||
|
||||
// عملة الطعام تتبع دولة التشغيل. الأردن أولاً — والثابت يبقى مقروءاً من
|
||||
// البيئة حتى لا يتحول إلى رقم مدفون عند التوسع.
|
||||
$currency = strtoupper((string) (getenv('FOOD_CURRENCY') ?: 'JOD'));
|
||||
|
||||
try {
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ١) القيود غير المسوّاة في الفترة
|
||||
//
|
||||
// settlement_id IS NULL هو الضمانة ضد الاحتساب المزدوج: قيد ضُمّ
|
||||
// لدورة سابقة لا يعود في هذه.
|
||||
// ══════════════════════════════════════════════════════════
|
||||
$st = $food_con->prepare("
|
||||
SELECT p.id, p.type, p.amount
|
||||
FROM food_order_payments p
|
||||
JOIN food_orders o ON o.id = p.order_id
|
||||
WHERE o.courier_id = ?
|
||||
AND o.delivered_at BETWEEN ? AND ?
|
||||
AND p.settlement_id IS NULL
|
||||
AND p.type IN ('courier_payout', 'cash_settlement')
|
||||
");
|
||||
$st->execute([$courierId, $periodStart, $periodEnd]);
|
||||
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$payoutTotal = 0;
|
||||
$cashDebt = 0;
|
||||
$payoutIds = [];
|
||||
|
||||
foreach ($rows as $r) {
|
||||
$payoutIds[] = (int) $r['id'];
|
||||
if ($r['type'] === 'courier_payout') {
|
||||
$payoutTotal += (int) $r['amount'];
|
||||
} else {
|
||||
$cashDebt += (int) $r['amount'];
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ٢) المُرحَّل من دورة سابقة سالبة
|
||||
// ══════════════════════════════════════════════════════════
|
||||
$stCarry = $food_con->prepare("
|
||||
SELECT COALESCE(SUM(-net_amount), 0)
|
||||
FROM food_courier_settlements
|
||||
WHERE courier_id = ? AND status = 'carried'
|
||||
");
|
||||
$stCarry->execute([$courierId]);
|
||||
$carriedOver = (int) $stCarry->fetchColumn();
|
||||
|
||||
$net = $payoutTotal - $cashDebt - $carriedOver;
|
||||
|
||||
$stCount = $food_con->prepare("
|
||||
SELECT COUNT(*) FROM food_orders
|
||||
WHERE courier_id = ? AND status = 'delivered'
|
||||
AND delivered_at BETWEEN ? AND ?
|
||||
");
|
||||
$stCount->execute([$courierId, $periodStart, $periodEnd]);
|
||||
$ordersCount = (int) $stCount->fetchColumn();
|
||||
|
||||
$summary = [
|
||||
'courier_id' => $courierId,
|
||||
'period_start' => $periodStart,
|
||||
'period_end' => $periodEnd,
|
||||
'orders_count' => $ordersCount,
|
||||
'payout_total' => $payoutTotal,
|
||||
'cash_debt_total' => $cashDebt,
|
||||
'carried_over' => $carriedOver,
|
||||
'net_amount' => $net,
|
||||
'net_decimal' => foodSmallestUnitToDecimal(abs($net)) * ($net < 0 ? -1 : 1),
|
||||
'currency' => $currency,
|
||||
'entries_count' => count($payoutIds),
|
||||
];
|
||||
|
||||
// ── المعاينة تتوقف هنا ──────────────────────────────────
|
||||
if ($action === 'preview') {
|
||||
$summary['action'] = 'preview';
|
||||
$summary['would_pay'] = $net > 0;
|
||||
jsonSuccess($summary, 'Settlement preview');
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ٣) التنفيذ
|
||||
// ══════════════════════════════════════════════════════════
|
||||
if (empty($payoutIds) && $carriedOver === 0) {
|
||||
jsonError('No unsettled entries for this courier and period');
|
||||
}
|
||||
|
||||
$status = $net > 0 ? 'draft' : 'carried';
|
||||
|
||||
$food_con->beginTransaction();
|
||||
|
||||
$ins = $food_con->prepare("
|
||||
INSERT INTO food_courier_settlements
|
||||
(courier_id, period_start, period_end, orders_count,
|
||||
payout_total, cash_debt_total, carried_over, net_amount,
|
||||
currency, status, executed_by, executed_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW())
|
||||
");
|
||||
$ins->execute([
|
||||
$courierId, $periodStart, $periodEnd, $ordersCount,
|
||||
$payoutTotal, $cashDebt, $carriedOver, $net,
|
||||
$currency, $status, $food_admin_id ?: 'admin',
|
||||
]);
|
||||
$settlementId = (int) $food_con->lastInsertId();
|
||||
|
||||
// وسم القيود قبل الصرف: لو انقطع التنفيذ بعد التحويل وقبل الوسم،
|
||||
// لأُعيد صرفها في الدورة التالية.
|
||||
if (!empty($payoutIds)) {
|
||||
$ph = implode(',', array_fill(0, count($payoutIds), '?'));
|
||||
$food_con->prepare("
|
||||
UPDATE food_order_payments
|
||||
SET settlement_id = ?, status = 'success'
|
||||
WHERE id IN ($ph)
|
||||
")->execute(array_merge([$settlementId], $payoutIds));
|
||||
}
|
||||
|
||||
// الدورات السالبة المُرحَّلة استُهلكت في هذا الحساب — نغلقها حتى لا
|
||||
// تُطرح مرة أخرى في الدورة القادمة.
|
||||
if ($carriedOver > 0) {
|
||||
$food_con->prepare("
|
||||
UPDATE food_courier_settlements
|
||||
SET status = 'settled_forward', note = CONCAT(COALESCE(note,''), ' → #', ?)
|
||||
WHERE courier_id = ? AND status = 'carried' AND id <> ?
|
||||
")->execute([$settlementId, $courierId, $settlementId]);
|
||||
}
|
||||
|
||||
$food_con->commit();
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ٤) التحويل — بعد الـcommit لا داخله
|
||||
// ══════════════════════════════════════════════════════════
|
||||
$transferCode = null;
|
||||
|
||||
if ($net > 0) {
|
||||
$transferCode = foodPayCourier(
|
||||
$courierId,
|
||||
foodSmallestUnitToDecimal($net),
|
||||
$settlementId
|
||||
);
|
||||
|
||||
$food_con->prepare("
|
||||
UPDATE food_courier_settlements
|
||||
SET status = ?, transfer_code = ?
|
||||
WHERE id = ?
|
||||
")->execute([$transferCode === 200 ? 'paid' : 'failed', $transferCode, $settlementId]);
|
||||
|
||||
if ($transferCode !== 200) {
|
||||
error_log("[FOOD][SETTLEMENT] MONEY: تسوية #$settlementId بقيمة $net"
|
||||
. " للسائق $courierId لم تصل (رمز=$transferCode)");
|
||||
}
|
||||
}
|
||||
|
||||
$summary['settlement_id'] = $settlementId;
|
||||
$summary['status'] = $net > 0
|
||||
? ($transferCode === 200 ? 'paid' : 'failed')
|
||||
: 'carried';
|
||||
$summary['transfer_code'] = $transferCode;
|
||||
|
||||
jsonSuccess($summary, 'Settlement executed');
|
||||
|
||||
} catch (PDOException $e) {
|
||||
if (isset($food_con) && $food_con->inTransaction()) $food_con->rollBack();
|
||||
error_log("[FOOD][SETTLEMENT] " . $e->getMessage());
|
||||
jsonError('DB Error', 500);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* يودع صافي التسوية في محفظة السائق.
|
||||
*
|
||||
* يستخدم driverWallet/add_s2s_reward.php — وهو العقد الذي أكّدناه عملياً
|
||||
* في تعويض عدم الحضور ورسوم الإلغاء وتعويضات الشكاوى. تعليق delivered.php
|
||||
* يقول إن العقد المؤكد لتحويلات سائق↔سائق فقط، وهذا لم يعد صحيحاً.
|
||||
*
|
||||
* paymentID مشتق من رقم التسوية فيمنع ازدواج الصرف عند إعادة المحاولة.
|
||||
*/
|
||||
function foodPayCourier(string $courierId, float $amount, int $settlementId): int
|
||||
{
|
||||
$walletServer = foodWalletServerUrl();
|
||||
$url = "$walletServer/v2/main/ride/driverWallet/add_s2s_reward.php";
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'driverID' => $courierId,
|
||||
'paymentID' => "food_settlement_$settlementId",
|
||||
'amount' => $amount,
|
||||
'paymentMethod' => 'food_courier_settlement',
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
|
||||
],
|
||||
]);
|
||||
curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
return $code;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
// food/admin/merchant_approve.php — اعتماد أو رفض مطعم بحالة pending_approval
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
|
||||
$merchantId = filterRequest('merchant_id', 'int');
|
||||
$action = filterRequest('action'); // 'approve' | 'reject'
|
||||
if (!$merchantId || !in_array($action, ['approve', 'reject'], true)) {
|
||||
jsonError('merchant_id and action (approve|reject) are required');
|
||||
}
|
||||
|
||||
$st = $food_con->prepare("SELECT id, status FROM food_merchants WHERE id=? LIMIT 1");
|
||||
$st->execute([$merchantId]);
|
||||
$merchant = $st->fetch();
|
||||
if (!$merchant) jsonError('Merchant not found', 404);
|
||||
if ($merchant['status'] !== 'pending_approval') jsonError('Merchant is not pending approval', 409);
|
||||
|
||||
$newStatus = $action === 'approve' ? 'active' : 'rejected';
|
||||
|
||||
$food_con->prepare(
|
||||
"UPDATE food_merchants SET status=?, approved_by=?, approved_at=NOW() WHERE id=?"
|
||||
)->execute([$newStatus, $food_admin_id, $merchantId]);
|
||||
|
||||
jsonSuccess(['merchant_id' => $merchantId, 'status' => $newStatus]);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// food/admin/merchant_create.php — إنشاء مطعم جديد بحالة pending_approval + حساب مالكه
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
|
||||
requireFoodFields(['name_ar', 'city', 'address', 'latitude', 'longitude', 'owner_name', 'owner_phone', 'owner_password']);
|
||||
|
||||
$nameAr = filterRequest('name_ar');
|
||||
$nameEn = filterRequest('name_en');
|
||||
$city = filterRequest('city');
|
||||
$address = filterRequest('address');
|
||||
$lat = filterRequest('latitude', 'float');
|
||||
$lng = filterRequest('longitude', 'float');
|
||||
$category = filterRequest('category');
|
||||
$commission = filterRequest('commission_percent', 'float') ?? (float)(getenv('FOOD_COMMISSION_PERCENT') ?: 15);
|
||||
|
||||
$ownerName = filterRequest('owner_name');
|
||||
$ownerPhone = normalizePhone(filterRequest('owner_phone'));
|
||||
$ownerPassword = filterRequest('owner_password');
|
||||
|
||||
if (strlen($ownerPassword) < 8) jsonError('owner_password must be at least 8 characters');
|
||||
|
||||
$dupSt = $food_con->prepare("SELECT id FROM food_merchant_users WHERE phone=? LIMIT 1");
|
||||
$dupSt->execute([$ownerPhone]);
|
||||
if ($dupSt->fetch()) jsonError('A merchant account already uses this phone', 409);
|
||||
|
||||
$food_con->beginTransaction();
|
||||
try {
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_merchants
|
||||
(name_ar, name_en, city, address, latitude, longitude, category, commission_percent, status)
|
||||
VALUES (?,?,?,?,?,?,?,?,'pending_approval')"
|
||||
)->execute([$nameAr, $nameEn, $city, $address, $lat, $lng, $category, $commission]);
|
||||
|
||||
$merchantId = (int)$food_con->lastInsertId();
|
||||
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_merchant_users (merchant_id, name, phone, role, password_hash)
|
||||
VALUES (?,?,?,'owner',?)"
|
||||
)->execute([$merchantId, $ownerName, $ownerPhone, password_hash($ownerPassword, PASSWORD_DEFAULT)]);
|
||||
|
||||
$food_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$food_con->rollBack();
|
||||
appLog('[FOOD][ADMIN][merchant_create] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to create merchant', 500);
|
||||
}
|
||||
|
||||
jsonSuccess(['merchant_id' => $merchantId, 'status' => 'pending_approval'], 'Merchant created — awaiting approval');
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// food/admin/merchants.php — قائمة المطاعم لإدارة سيرو (فلترة حسب الحالة)
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
|
||||
$status = filterRequest('status') ?: 'all';
|
||||
$allowed = ['all', 'pending_approval', 'active', 'paused', 'suspended', 'rejected'];
|
||||
if (!in_array($status, $allowed, true)) $status = 'all';
|
||||
|
||||
$sql = "SELECT id, name_ar, name_en, city, category, status, commission_percent,
|
||||
rating_avg, rating_count, created_at, approved_at
|
||||
FROM food_merchants";
|
||||
$params = [];
|
||||
if ($status !== 'all') {
|
||||
$sql .= " WHERE status=?";
|
||||
$params[] = $status;
|
||||
}
|
||||
$sql .= " ORDER BY created_at DESC";
|
||||
|
||||
$st = $food_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
|
||||
jsonSuccess(['merchants' => $st->fetchAll()]);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// food/admin/payouts.php — تقرير تسويات المطاعم للفترة المطلوبة (توليد لا صرف آلي)
|
||||
//
|
||||
// ⚠️ هذا يُنتج تقريراً محاسبياً (food_merchant_payouts بحالة pending) — لا يحوّل
|
||||
// أموالاً فعلياً. الصرف الفعلي للمطاعم والسائقين خارج نطاق هذه الوحدة حتى يُؤكَّد
|
||||
// عقد S2S مخصص لذلك (انظر التنبيهات في food/functions.php حول foodWalletMove).
|
||||
require_once __DIR__ . '/../connect_admin.php';
|
||||
|
||||
$merchantId = filterRequest('merchant_id', 'int');
|
||||
$periodStart = filterRequest('period_start');
|
||||
$periodEnd = filterRequest('period_end');
|
||||
requireFoodFields(['merchant_id', 'period_start', 'period_end']);
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT COUNT(*) orders_count, COALESCE(SUM(items_total),0) gross_amount, COALESCE(SUM(commission_amount),0) commission_amount
|
||||
FROM food_orders
|
||||
WHERE merchant_id=? AND status='delivered' AND delivered_at BETWEEN ? AND ?"
|
||||
);
|
||||
$st->execute([$merchantId, $periodStart, $periodEnd]);
|
||||
$summary = $st->fetch();
|
||||
|
||||
$netPayout = (int)$summary['gross_amount'] - (int)$summary['commission_amount'];
|
||||
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_merchant_payouts (merchant_id, period_start, period_end, orders_count, gross_amount, commission_amount, net_payout, status)
|
||||
VALUES (?,?,?,?,?,?,?,'pending')"
|
||||
)->execute([
|
||||
$merchantId, $periodStart, $periodEnd, $summary['orders_count'],
|
||||
$summary['gross_amount'], $summary['commission_amount'], $netPayout,
|
||||
]);
|
||||
|
||||
jsonSuccess([
|
||||
'merchant_id' => $merchantId,
|
||||
'orders_count' => (int)$summary['orders_count'],
|
||||
'gross_amount' => (int)$summary['gross_amount'],
|
||||
'commission_amount' => (int)$summary['commission_amount'],
|
||||
'net_payout' => $netPayout,
|
||||
'status' => 'pending',
|
||||
], 'Payout report generated');
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
// food/cart/quote.php — يحسب السعر من الخادم فقط ويوقّعه لمدة 10 دقائق
|
||||
// body: merchant_id, items:[{item_id, quantity, options:[{option_group_id, choice_ids:[]}]}]
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$merchantId = filterRequest('merchant_id', 'int');
|
||||
$itemsRaw = json_decode(filterRequest('items') ?? '[]', true);
|
||||
if (!$merchantId || !$itemsRaw || !is_array($itemsRaw)) {
|
||||
jsonError('merchant_id and items are required');
|
||||
}
|
||||
|
||||
$merchantSt = $food_con->prepare(
|
||||
"SELECT id, min_order_amount, is_open_override, working_hours FROM food_merchants WHERE id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$merchantSt->execute([$merchantId]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
if (!$merchant) jsonError('Merchant not found', 404);
|
||||
if (!foodIsMerchantOpen($merchant)) jsonError('Merchant is currently closed', 409);
|
||||
|
||||
$lines = [];
|
||||
$itemsTotal = 0;
|
||||
|
||||
foreach ($itemsRaw as $line) {
|
||||
$itemId = (int)($line['item_id'] ?? 0);
|
||||
$quantity = max(1, (int)($line['quantity'] ?? 1));
|
||||
if (!$itemId) jsonError('Invalid item in cart');
|
||||
|
||||
$itemSt = $food_con->prepare(
|
||||
"SELECT id, merchant_id, name_ar, price, is_available FROM food_menu_items WHERE id=? LIMIT 1"
|
||||
);
|
||||
$itemSt->execute([$itemId]);
|
||||
$item = $itemSt->fetch();
|
||||
if (!$item || (int)$item['merchant_id'] !== $merchantId) jsonError("Item $itemId does not belong to this merchant", 400);
|
||||
if (!$item['is_available']) jsonError("{$item['name_ar']} is currently unavailable", 409);
|
||||
|
||||
$optionsSt = $food_con->prepare("SELECT id, choices, is_required, max_select FROM food_item_options WHERE item_id=?");
|
||||
$optionsSt->execute([$itemId]);
|
||||
$optionGroups = $optionsSt->fetchAll();
|
||||
|
||||
$chosenOptionsOut = [];
|
||||
$optionsTotalPerUnit = 0;
|
||||
$requestedGroups = $line['options'] ?? [];
|
||||
|
||||
foreach ($optionGroups as $group) {
|
||||
$choices = json_decode($group['choices'], true) ?: [];
|
||||
$choiceById = array_column($choices, null, 'id');
|
||||
$requested = null;
|
||||
foreach ($requestedGroups as $rg) {
|
||||
if ((int)($rg['option_group_id'] ?? 0) === (int)$group['id']) { $requested = $rg; break; }
|
||||
}
|
||||
$choiceIds = $requested['choice_ids'] ?? [];
|
||||
|
||||
if ($group['is_required'] && empty($choiceIds)) {
|
||||
jsonError("Required option group missing for item {$item['name_ar']}", 400);
|
||||
}
|
||||
if (count($choiceIds) > (int)$group['max_select']) {
|
||||
jsonError("Too many choices selected for item {$item['name_ar']}", 400);
|
||||
}
|
||||
|
||||
foreach ($choiceIds as $cid) {
|
||||
if (!isset($choiceById[$cid])) jsonError("Invalid option choice for item {$item['name_ar']}", 400);
|
||||
$optionsTotalPerUnit += (int)$choiceById[$cid]['price'];
|
||||
$chosenOptionsOut[] = ['group_id' => (int)$group['id'], 'choice_id' => $cid, 'price' => (int)$choiceById[$cid]['price']];
|
||||
}
|
||||
}
|
||||
|
||||
$unitPrice = (int)$item['price'] + $optionsTotalPerUnit;
|
||||
$lineTotal = $unitPrice * $quantity;
|
||||
$itemsTotal += $lineTotal;
|
||||
|
||||
$lines[] = [
|
||||
'item_id' => $itemId,
|
||||
'name_ar_snapshot' => $item['name_ar'],
|
||||
'unit_price' => $unitPrice,
|
||||
'quantity' => $quantity,
|
||||
'options' => $chosenOptionsOut,
|
||||
'line_total' => $lineTotal,
|
||||
];
|
||||
}
|
||||
|
||||
if ($itemsTotal < (int)$merchant['min_order_amount']) {
|
||||
jsonError('Order does not meet minimum order amount', 409, ['min_order_amount' => (int)$merchant['min_order_amount']]);
|
||||
}
|
||||
|
||||
// رسم التوصيل — ثابت من env حتى يُربط بمحرك التسعير القائم في مرحلة لاحقة
|
||||
$deliveryFee = (int)(getenv('FOOD_DELIVERY_BASE_FEE') ?: 1000);
|
||||
$serviceFee = 0;
|
||||
$grandTotal = $itemsTotal + $deliveryFee + $serviceFee;
|
||||
|
||||
$quote = [
|
||||
'merchant_id' => $merchantId,
|
||||
'passenger_id' => $food_passenger_id,
|
||||
'lines' => $lines,
|
||||
'items_total' => $itemsTotal,
|
||||
'delivery_fee' => $deliveryFee,
|
||||
'service_fee' => $serviceFee,
|
||||
'grand_total' => $grandTotal,
|
||||
];
|
||||
|
||||
$token = foodSignQuote($quote);
|
||||
|
||||
jsonSuccess([
|
||||
'quote_token' => $token,
|
||||
'items_total' => $itemsTotal,
|
||||
'delivery_fee' => $deliveryFee,
|
||||
'service_fee' => $serviceFee,
|
||||
'grand_total' => $grandTotal,
|
||||
'lines' => $lines,
|
||||
'expires_in' => 600,
|
||||
]);
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/connect_admin.php — بوابة إدارة سيرو (اعتماد المطاعم، التسويات...)
|
||||
// يستخدم JWT الإداري نفسه المستخدم في backend/Admin (role admin/super_admin)
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
$jwtService = new JwtService($redis);
|
||||
$decoded = $jwtService->authenticate();
|
||||
|
||||
$food_admin_role = $decoded->role ?? '';
|
||||
if (!in_array($food_admin_role, ['admin', 'super_admin'], true)) {
|
||||
jsonError('Forbidden — admin token required', 403);
|
||||
}
|
||||
$food_admin_id = (string)($decoded->user_id ?? '');
|
||||
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
// السبب الحقيقي في اللوج فقط — الرد يبقى عاماً.
|
||||
error_log('[FOOD] DB connection failed in ' . basename(__FILE__) . ': ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/connect_app.php — بوابة الزبون (تبويب «طعام» داخل siro_rider)
|
||||
// يستخدم JWT الرئيسي نفسه — الزبون هو الراكب نفسه، لا حساب ثانٍ
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
if (getenv('FOOD_ENABLED') === 'false') {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Rate limiting — نفس حد API العادي
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
// JWT المعتاد — راكب فقط (لا يُقبل توكن سائق هنا)
|
||||
$jwtService = new JwtService($redis);
|
||||
$decoded = $jwtService->authenticate();
|
||||
|
||||
if (($decoded->role ?? '') !== 'passenger') {
|
||||
jsonError('Forbidden — passenger token required', 403);
|
||||
}
|
||||
$food_passenger_id = (string)($decoded->user_id ?? '');
|
||||
|
||||
// اتصال قاعدة بيانات food فقط — ممنوع Database::get('main') في ملفات food/
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
// السبب الحقيقي في اللوج فقط — الرد يبقى عاماً.
|
||||
error_log('[FOOD] DB connection failed in ' . basename(__FILE__) . ': ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/connect_courier.php — بوابة السائق (نوع مهمة «توصيل» داخل تدفّق العروض القائم)
|
||||
// يستخدم JWT الرئيسي نفسه — السائق هو الكابتن نفسه، لا حساب ثانٍ
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
if (getenv('FOOD_ENABLED') === 'false') {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
$jwtService = new JwtService($redis);
|
||||
$decoded = $jwtService->authenticate();
|
||||
|
||||
if (($decoded->role ?? '') !== 'driver') {
|
||||
jsonError('Forbidden — driver token required', 403);
|
||||
}
|
||||
$food_courier_id = (string)($decoded->user_id ?? '');
|
||||
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
// السبب الحقيقي في اللوج فقط — الرد يبقى عاماً.
|
||||
error_log('[FOOD] DB connection failed in ' . basename(__FILE__) . ': ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
|
||||
exit;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/connect_merchant.php — بوابة لوحة المطعم (ويب متجاوب)
|
||||
// المصادقة عبر session token (هاتف + كلمة مرور) — مستقلة عن JWT
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
if (getenv('FOOD_ENABLED') === 'false') {
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service is currently disabled']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// CORS للوحة المطعم
|
||||
$merchantOrigins = array_map('trim', explode(',',
|
||||
getenv('FOOD_MERCHANT_ORIGINS') ?: 'https://food-merchant.siromove.com,https://admin.siromove.com'
|
||||
));
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
if (in_array($origin, $merchantOrigins)) {
|
||||
header("Access-Control-Allow-Origin: $origin");
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'api');
|
||||
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
// السبب الحقيقي في اللوج فقط — الرد يبقى عاماً.
|
||||
error_log('[FOOD] DB connection failed in ' . basename(__FILE__) . ': ' . $e->getMessage());
|
||||
http_response_code(503);
|
||||
echo json_encode(['status' => 'failure', 'message' => 'Food service unavailable']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// التحقق من الـ session (يُعيد ['merchant_user_id'=>X, 'merchant_id'=>Y])
|
||||
$food_merchant_session = foodAuthMerchant();
|
||||
$food_merchant_user_id = (int)$food_merchant_session['merchant_user_id'];
|
||||
$food_merchant_id = (int)$food_merchant_session['merchant_id'];
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
// food/courier/active.php — طلبات التوصيل الحالية للسائق (polling fallback + شاشة المهمة)
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.latitude AS merchant_lat,
|
||||
m.longitude AS merchant_lng, m.address AS merchant_address, m.avg_prep_minutes,
|
||||
o.delivery_fee, o.items_total, o.service_fee, o.discount, o.grand_total, o.payment_method,
|
||||
o.customer_note, o.delivery_address, o.delivery_lat, o.delivery_lng,
|
||||
o.created_at, o.ready_at, o.courier_assigned_at, o.picked_up_at,
|
||||
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count,
|
||||
CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS visible_address
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.courier_id = ? AND o.status IN ('courier_assigned','picked_up')
|
||||
ORDER BY o.courier_assigned_at ASC"
|
||||
);
|
||||
$st->execute([$food_courier_id]);
|
||||
$orders = $st->fetchAll();
|
||||
|
||||
// بيانات الزبون (العنوان) تظهر فقط بعد courier_assigned وتُحجب بعد delivered — لا نُعيد أي طلب مسلَّم هنا أصلاً
|
||||
foreach ($orders as &$o) {
|
||||
unset($o['delivery_address']);
|
||||
$o['delivery_address'] = $o['visible_address'];
|
||||
unset($o['visible_address']);
|
||||
|
||||
$o['items_count'] = (int)$o['items_count'];
|
||||
// نقداً: السائق يحصّل grand_total من الزبون ويحتفظ بأجرته (delivery_fee)،
|
||||
// والباقي يبقى ديناً عليه حتى التسوية — نُظهر الرقمين صراحةً في التطبيق
|
||||
// كي لا يجتهد السائق في الحساب على باب الزبون.
|
||||
$o['cash_to_collect'] = $o['payment_method'] === 'cash' ? (int)$o['grand_total'] : 0;
|
||||
$o['courier_owes'] = $o['payment_method'] === 'cash'
|
||||
? (int)$o['grand_total'] - (int)$o['delivery_fee']
|
||||
: 0;
|
||||
}
|
||||
unset($o);
|
||||
|
||||
jsonSuccess(['orders' => $orders]);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// food/courier/call_customer.php — السائق يتصل بالزبون عبر قناة مقنّعة
|
||||
// لا رقم هاتف يُعرض لأي طرف: نفتح جلسة WebRTC ونُشعر الزبون بمعرّف الجلسة فقط.
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
|
||||
|
||||
if (!foodOrderAllowsCall($order)) {
|
||||
// بعد التسليم أو قبل الإسناد لا حاجة تشغيلية للاتصال — والقناة تُقفل
|
||||
jsonError('Calling is only allowed while the delivery is active', 403);
|
||||
}
|
||||
|
||||
if (foodCallQuotaExceeded($orderId, 'courier')) {
|
||||
jsonError('Too many call attempts for this order', 429);
|
||||
}
|
||||
|
||||
$session = foodCreateCallSession($orderId, $food_courier_id, (string)$order['passenger_id']);
|
||||
if (!$session) jsonError('Call service unavailable', 502);
|
||||
|
||||
// إشعار صامت للزبون عبر موضوع FCM الخاص به — نفس القناة المستعملة في وحدة
|
||||
// الطعام أصلاً (ممنوع Database::get('main') هنا لجلب توكن الجهاز).
|
||||
// الاسم المعروض عام عمداً: هوية السائق الحقيقية لا تُكشف للزبون.
|
||||
foodSendNotificationToPassenger(
|
||||
(string)$order['passenger_id'],
|
||||
'مكالمة واردة',
|
||||
'سائق التوصيل يتصل بك',
|
||||
[
|
||||
// تطبيق الراكب يفرز الإشعارات بـ data['category'] — ونُبقي 'type'
|
||||
// للتوافق مع بقية حمولات وحدة الطعام.
|
||||
'category' => 'incoming_call',
|
||||
'type' => 'incoming_call',
|
||||
'session_id' => $session['session_id'],
|
||||
'caller_name' => 'سائق التوصيل',
|
||||
'caller_avatar' => '',
|
||||
'ride_id' => 'food_' . $orderId,
|
||||
'food_order_id' => (string)$orderId,
|
||||
]
|
||||
);
|
||||
|
||||
appLog("[FOOD][CALL] courier {$food_courier_id} → passenger (order {$orderId})", 'INFO');
|
||||
|
||||
jsonSuccess([
|
||||
'session_id' => $session['session_id'],
|
||||
'expires_in' => $session['expires_in'],
|
||||
]);
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
// food/courier/delivered.php — تسليم الطلب — تثبيت المال (capture) وقيد أرباح السائق
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
|
||||
food_transition_status($orderId, 'delivered', 'courier', $food_courier_id);
|
||||
|
||||
if ($order['payment_method'] === 'wallet') {
|
||||
// المبلغ خُصم بالكامل عند الإنشاء (انظر ملاحظة foodWalletMove) — capture هنا تسجيل محاسبي فقط
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'capture',?,'success')"
|
||||
)->execute([$orderId, (int)$order['grand_total']]);
|
||||
} else {
|
||||
// نقداً: السائق حصّل grand_total كاملاً من الزبون. يحتفظ بـ delivery_fee (أجرته)
|
||||
// ويبقى ديناً عليه الباقي (items_total+service_fee — حصة المطعم والمنصة) حتى يُسوّى
|
||||
// إدارياً. هذا قيد محاسبي فقط هنا — لا حركة مالية آلية فعلية بعد (انظر التنبيه في
|
||||
// admin/payouts.php حول عدم وجود عقد API مؤكد لتحصيل ديون السائق النقدية آلياً).
|
||||
$courierOwed = (int)$order['grand_total'] - (int)$order['delivery_fee'];
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'cash_settlement',?,'pending')"
|
||||
)->execute([$orderId, $courierOwed]);
|
||||
}
|
||||
|
||||
// قيد أرباح السائق (أجرة التوصيل) — مُتراكم عمداً بقرار المالك: الأجرة
|
||||
// تُصرف دورياً مع مقاصّة دَين النقد، لا فورياً عند كل تسليم. صرفها منفصلة
|
||||
// عن الدَين يعني الدفع للسائق بينما هو يحمل مال المنصة من الطلبات النقدية.
|
||||
//
|
||||
// التسوية في food/admin/courier_settlement.php.
|
||||
//
|
||||
// (تصحيح لملاحظة سابقة هنا: عقد S2S لإيداع أرباح من المنصة **موجود
|
||||
// ومؤكد** — driverWallet/add_s2s_reward.php، وهو مستخدم في تعويض عدم
|
||||
// الحضور ورسوم الإلغاء وتعويضات الشكاوى.)
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'courier_payout',?,'pending')"
|
||||
)->execute([$orderId, (int)$order['delivery_fee']]);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'delivered']);
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
// food/courier/earnings.php — ملخص أرباح التوصيل للسائق (اليوم/الأسبوع/الشهر)
|
||||
// المبالغ من food_orders.delivery_fee للطلبات المسلَّمة فقط — نفس ما يُقيَّد في
|
||||
// food_order_payments (courier_payout)، والتسوية الفعلية خارج نطاق هذا الملف.
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$sums = $food_con->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total_orders,
|
||||
COALESCE(SUM(delivery_fee),0) AS total_earnings,
|
||||
COALESCE(SUM(CASE WHEN DATE(delivered_at)=CURDATE() THEN delivery_fee END),0) AS today_earnings,
|
||||
COUNT(CASE WHEN DATE(delivered_at)=CURDATE() THEN 1 END) AS today_orders,
|
||||
COALESCE(SUM(CASE WHEN delivered_at >= (NOW() - INTERVAL 7 DAY) THEN delivery_fee END),0) AS week_earnings,
|
||||
COUNT(CASE WHEN delivered_at >= (NOW() - INTERVAL 7 DAY) THEN 1 END) AS week_orders,
|
||||
COALESCE(SUM(CASE WHEN delivered_at >= (NOW() - INTERVAL 30 DAY) THEN delivery_fee END),0) AS month_earnings,
|
||||
COUNT(CASE WHEN delivered_at >= (NOW() - INTERVAL 30 DAY) THEN 1 END) AS month_orders
|
||||
FROM food_orders
|
||||
WHERE courier_id=? AND status='delivered'"
|
||||
);
|
||||
$sums->execute([$food_courier_id]);
|
||||
$row = $sums->fetch() ?: [];
|
||||
|
||||
// ديون التحصيل النقدي غير المسوّاة — السائق يحتاج يعرف كم عليه قبل ما يفاجأ بخصم
|
||||
$owedSt = $food_con->prepare(
|
||||
"SELECT COALESCE(SUM(p.amount),0) AS owed
|
||||
FROM food_order_payments p
|
||||
JOIN food_orders o ON o.id = p.order_id
|
||||
WHERE o.courier_id=? AND p.type='cash_settlement' AND p.status='pending'"
|
||||
);
|
||||
$owedSt->execute([$food_courier_id]);
|
||||
|
||||
// آخر 7 أيام مفصّلة — للرسم البياني في التطبيق
|
||||
$dailySt = $food_con->prepare(
|
||||
"SELECT DATE(delivered_at) AS day, COUNT(*) AS orders_count, COALESCE(SUM(delivery_fee),0) AS earnings
|
||||
FROM food_orders
|
||||
WHERE courier_id=? AND status='delivered' AND delivered_at >= (NOW() - INTERVAL 7 DAY)
|
||||
GROUP BY DATE(delivered_at)
|
||||
ORDER BY day DESC"
|
||||
);
|
||||
$dailySt->execute([$food_courier_id]);
|
||||
|
||||
jsonSuccess([
|
||||
'today_earnings' => (int)($row['today_earnings'] ?? 0),
|
||||
'today_orders' => (int)($row['today_orders'] ?? 0),
|
||||
'week_earnings' => (int)($row['week_earnings'] ?? 0),
|
||||
'week_orders' => (int)($row['week_orders'] ?? 0),
|
||||
'month_earnings' => (int)($row['month_earnings'] ?? 0),
|
||||
'month_orders' => (int)($row['month_orders'] ?? 0),
|
||||
'total_earnings' => (int)($row['total_earnings'] ?? 0),
|
||||
'total_orders' => (int)($row['total_orders'] ?? 0),
|
||||
'pending_cash_owed' => (int)($owedSt->fetch()['owed'] ?? 0),
|
||||
'daily' => array_map(static fn($d) => [
|
||||
'day' => $d['day'],
|
||||
'orders_count' => (int)$d['orders_count'],
|
||||
'earnings' => (int)$d['earnings'],
|
||||
], $dailySt->fetchAll()),
|
||||
]);
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// food/courier/history.php — سجل طلبات التوصيل المنتهية لهذا السائق
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$limit = (int)(filterRequest('limit', 'int') ?: 20);
|
||||
$offset = (int)(filterRequest('offset', 'int') ?: 0);
|
||||
$limit = max(1, min($limit, 50));
|
||||
$offset = max(0, $offset);
|
||||
|
||||
// العنوان النصّي للزبون لا يُعاد في السجل — انتهت الحاجة التشغيلية إليه بالتسليم،
|
||||
// ونفس قاعدة الحجب المطبَّقة في active.php.
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.id, o.status, o.merchant_id, m.name_ar AS merchant_name_ar, m.address AS merchant_address,
|
||||
o.delivery_fee, o.grand_total, o.payment_method, o.rating,
|
||||
o.courier_assigned_at, o.picked_up_at, o.delivered_at, o.created_at,
|
||||
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count,
|
||||
TIMESTAMPDIFF(MINUTE, o.courier_assigned_at, o.delivered_at) AS duration_minutes
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.courier_id = ? AND o.status = 'delivered'
|
||||
ORDER BY o.delivered_at DESC
|
||||
LIMIT $limit OFFSET $offset"
|
||||
);
|
||||
$st->execute([$food_courier_id]);
|
||||
|
||||
$orders = array_map(static function (array $o): array {
|
||||
$o['items_count'] = (int)$o['items_count'];
|
||||
$o['delivery_fee'] = (int)$o['delivery_fee'];
|
||||
$o['grand_total'] = (int)$o['grand_total'];
|
||||
$o['duration_minutes'] = $o['duration_minutes'] === null ? null : (int)$o['duration_minutes'];
|
||||
return $o;
|
||||
}, $st->fetchAll());
|
||||
|
||||
jsonSuccess(['orders' => $orders, 'limit' => $limit, 'offset' => $offset]);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
// food/courier/location.php — بثّ موقع السائق أثناء مهمة توصيل نشطة
|
||||
//
|
||||
// خصوصية: الموقع يُقبل فقط ما دام الطلب في courier_assigned/picked_up، ويُخزَّن
|
||||
// في Redis بعمر 90 ثانية لا في قاعدة البيانات. بمجرد التسليم يرفض الخادم أي
|
||||
// تحديث، وينتهي آخر موقع تلقائياً — فلا يتحول التتبّع إلى مراقبة للسائق بعد
|
||||
// انتهاء عمله، ولا يبقى أثر دائم لتحركاته في وحدة الطعام.
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$lat = filterRequest('lat');
|
||||
$lng = filterRequest('lng');
|
||||
|
||||
if (!$orderId || $lat === null || $lng === null) {
|
||||
jsonError('order_id, lat and lng are required');
|
||||
}
|
||||
|
||||
$lat = (float)$lat;
|
||||
$lng = (float)$lng;
|
||||
if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180 || ($lat === 0.0 && $lng === 0.0)) {
|
||||
jsonError('Invalid coordinates');
|
||||
}
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
|
||||
|
||||
if (!in_array($order['status'], ['courier_assigned', 'picked_up'], true)) {
|
||||
jsonError('Location sharing is only allowed while the delivery is active', 403);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'order_id' => $orderId,
|
||||
'lat' => round($lat, 6),
|
||||
'lng' => round($lng, 6),
|
||||
'heading' => filterRequest('heading') !== null ? (float)filterRequest('heading') : null,
|
||||
'ts' => time(),
|
||||
];
|
||||
|
||||
if ($redis) {
|
||||
// مصدر مسار الاحتياط: تطبيق الراكب يسحبه من order/courier_location.php
|
||||
$redis->setex("food:order:{$orderId}:courier_pos", 90, json_encode($payload));
|
||||
}
|
||||
|
||||
// المسار اللحظي — غرفة الزبون على سوكيت الطعام
|
||||
foodPushToSocket('courier_location', array_merge($payload, [
|
||||
'passenger_id' => (string)$order['passenger_id'],
|
||||
]));
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'ts' => $payload['ts']]);
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
// food/courier/offer_respond.php — قبول/رفض عرض توصيل
|
||||
// body: order_id, response ('accept'|'reject')
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$response = filterRequest('response');
|
||||
if (!$orderId || !in_array($response, ['accept', 'reject'], true)) {
|
||||
jsonError('order_id and response (accept|reject) are required');
|
||||
}
|
||||
|
||||
$assignSt = $food_con->prepare(
|
||||
"SELECT id FROM food_courier_assignments WHERE order_id=? AND courier_id=? AND status='offered'
|
||||
ORDER BY offered_at DESC LIMIT 1"
|
||||
);
|
||||
$assignSt->execute([$orderId, $food_courier_id]);
|
||||
$assignment = $assignSt->fetch();
|
||||
if (!$assignment) jsonError('No pending offer for this order', 404);
|
||||
|
||||
if ($response === 'reject') {
|
||||
$food_con->prepare("UPDATE food_courier_assignments SET status='rejected', responded_at=NOW() WHERE id=?")
|
||||
->execute([$assignment['id']]);
|
||||
|
||||
// أعد المحاولة على مرشّح آخر — يعيد استخدام نفس منطق ready.php
|
||||
$orderSt = $food_con->prepare("SELECT merchant_id FROM food_orders WHERE id=? AND status='ready'");
|
||||
$orderSt->execute([$orderId]);
|
||||
if ($order = $orderSt->fetch()) {
|
||||
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
|
||||
$merchantSt->execute([$order['merchant_id']]);
|
||||
if ($merchant = $merchantSt->fetch()) {
|
||||
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
|
||||
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
|
||||
$priorSt->execute([$orderId]);
|
||||
$prior = array_column($priorSt->fetchAll(), 'courier_id');
|
||||
foreach ($candidates as $candidateId) {
|
||||
if (in_array($candidateId, $prior, true)) continue;
|
||||
foodOfferOrderToCourier($orderId, $candidateId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'response' => 'rejected']);
|
||||
}
|
||||
|
||||
// accept — القفل الذرّي يمنع سباق القبول لو انتهت مهلة سابقة وعُرض على اثنين معاً
|
||||
if (!foodLockOrderForCourier($orderId, $food_courier_id)) {
|
||||
$owner = foodOrderLockOwner($orderId);
|
||||
if ($owner !== $food_courier_id) {
|
||||
jsonError('Order was already claimed by another courier', 409);
|
||||
}
|
||||
}
|
||||
|
||||
// الطلب لم يُسند لأي سائق بعد عند هذه النقطة — لا فحص ملكية هنا، فقط فحص الحالة أدناه
|
||||
$orderSt = $food_con->prepare("SELECT status FROM food_orders WHERE id=? LIMIT 1");
|
||||
$orderSt->execute([$orderId]);
|
||||
$orderRow = $orderSt->fetch();
|
||||
if (!$orderRow || $orderRow['status'] !== 'ready') {
|
||||
jsonError('Order is no longer available for assignment', 409);
|
||||
}
|
||||
|
||||
$food_con->beginTransaction();
|
||||
try {
|
||||
$food_con->prepare("UPDATE food_orders SET courier_id=? WHERE id=?")->execute([$food_courier_id, $orderId]);
|
||||
$food_con->prepare("UPDATE food_courier_assignments SET status='accepted', responded_at=NOW() WHERE id=?")
|
||||
->execute([$assignment['id']]);
|
||||
$food_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$food_con->rollBack();
|
||||
appLog('[FOOD][COURIER][offer_respond] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to accept offer', 500);
|
||||
}
|
||||
|
||||
food_transition_status($orderId, 'courier_assigned', 'courier', $food_courier_id);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'response' => 'accepted']);
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
// food/courier/order_details.php — تفاصيل مهمة توصيل واحدة (شاشة المهمة عند السائق)
|
||||
// أصناف الطلب + تفصيل المبالغ + هاتف المطعم للتواصل أثناء الاستلام
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
// يفشل لو لم يكن الطلب مُسنداً لهذا السائق — لا وصول لطلبات الآخرين
|
||||
foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.id, o.status, o.merchant_id, o.items_total, o.delivery_fee, o.service_fee, o.discount,
|
||||
o.grand_total, o.payment_method, o.customer_note, o.delivery_lat, o.delivery_lng,
|
||||
o.created_at, o.ready_at, o.courier_assigned_at, o.picked_up_at, o.delivered_at,
|
||||
CASE WHEN o.status IN ('courier_assigned','picked_up') THEN o.delivery_address ELSE NULL END AS delivery_address,
|
||||
m.name_ar AS merchant_name_ar, m.address AS merchant_address, m.latitude AS merchant_lat,
|
||||
m.longitude AS merchant_lng, m.avg_prep_minutes
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.id = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$orderId]);
|
||||
$order = $st->fetch();
|
||||
if (!$order) jsonError('Order not found', 404);
|
||||
|
||||
$itemsSt = $food_con->prepare(
|
||||
"SELECT name_ar_snapshot, quantity, unit_price, line_total, option_price_json
|
||||
FROM food_order_items WHERE order_id=? ORDER BY id ASC"
|
||||
);
|
||||
$itemsSt->execute([$orderId]);
|
||||
$items = array_map(static function (array $i): array {
|
||||
return [
|
||||
'name_ar' => $i['name_ar_snapshot'],
|
||||
'quantity' => (int)$i['quantity'],
|
||||
'unit_price' => (int)$i['unit_price'],
|
||||
'line_total' => (int)$i['line_total'],
|
||||
'options' => $i['option_price_json'] ? json_decode($i['option_price_json'], true) : null,
|
||||
];
|
||||
}, $itemsSt->fetchAll());
|
||||
|
||||
// هاتف المطعم — يُعاد فقط أثناء المهمة النشطة (قبل التسليم)، لا في السجل
|
||||
$merchantPhone = null;
|
||||
if (in_array($order['status'], ['courier_assigned', 'picked_up'], true)) {
|
||||
$phoneSt = $food_con->prepare(
|
||||
"SELECT phone FROM food_merchant_users
|
||||
WHERE merchant_id=? AND is_active=1 ORDER BY role='owner' DESC, id ASC LIMIT 1"
|
||||
);
|
||||
$phoneSt->execute([$order['merchant_id']]);
|
||||
$merchantPhone = $phoneSt->fetch()['phone'] ?? null;
|
||||
}
|
||||
|
||||
// ملاحظة: لا هاتف للزبون هنا — قاعدة siro_food معزولة ولا تحمل بيانات الراكب،
|
||||
// وممنوع Database::get('main') داخل backend/food/. التواصل مع الزبون يبقى عبر
|
||||
// عنوان التسليم والملاحظة، إلى أن تُضاف قناة اتصال مقنّعة كما في الرحلات.
|
||||
$order['items'] = $items;
|
||||
$order['items_count'] = array_sum(array_column($items, 'quantity'));
|
||||
$order['merchant_phone'] = $merchantPhone;
|
||||
$order['cash_to_collect'] = $order['payment_method'] === 'cash' ? (int)$order['grand_total'] : 0;
|
||||
$order['courier_owes'] = $order['payment_method'] === 'cash'
|
||||
? (int)$order['grand_total'] - (int)$order['delivery_fee']
|
||||
: 0;
|
||||
|
||||
jsonSuccess(['order' => $order]);
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
// food/courier/pending_offers.php — عروض التوصيل المعروضة على هذا السائق حالياً
|
||||
// (polling fallback — food_socket يدفع 'food_delivery_offer' لحظياً، وهذا احتياطي
|
||||
// لو انقطع اتصال السوكيت أو كان التطبيق لا يحمل اتصالاً حياً بالسوكيت)
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT a.order_id, a.offered_at
|
||||
FROM food_courier_assignments a
|
||||
JOIN food_orders o ON o.id = a.order_id
|
||||
WHERE a.courier_id = ? AND a.status = 'offered' AND a.offered_at > (NOW() - INTERVAL 20 SECOND)
|
||||
AND o.status = 'ready' AND o.courier_id IS NULL
|
||||
ORDER BY a.offered_at ASC"
|
||||
);
|
||||
$st->execute([$food_courier_id]);
|
||||
|
||||
// نفس حمولة مسار السوكيت حرفياً (foodBuildCourierOfferPayload) — شاشة العرض
|
||||
// عند السائق واحدة، فلا يجوز أن تختلف الحقول حسب المسار الذي وصل منه العرض.
|
||||
$offers = [];
|
||||
foreach ($st->fetchAll() as $row) {
|
||||
$payload = foodBuildCourierOfferPayload((int)$row['order_id']);
|
||||
if (!$payload) continue;
|
||||
$offers[] = array_merge(
|
||||
['order_id' => (int)$row['order_id'], 'offered_at' => $row['offered_at'], 'offer_ttl_seconds' => 20],
|
||||
$payload
|
||||
);
|
||||
}
|
||||
|
||||
jsonSuccess(['offers' => $offers]);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// food/courier/picked_up.php — السائق استلم الطلب من المطعم
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
foodAssertOrderOwnership($orderId, 'courier', $food_courier_id);
|
||||
food_transition_status($orderId, 'picked_up', 'courier', $food_courier_id);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'picked_up']);
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// food/courier/status.php — حالة السائق في وحدة التوصيل عند فتح التبويب
|
||||
// (وضع التوصيل مخزَّن في Redis لا في التطبيق — بدون هذا النداء يفتح التطبيق
|
||||
// على "مغلق" بينما السائق ما زال فعلياً في food:couriers:opted_in ويستقبل عروضاً)
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$enabled = false;
|
||||
if ($redis) {
|
||||
$enabled = (bool)$redis->sIsMember('food:couriers:opted_in', $food_courier_id);
|
||||
}
|
||||
|
||||
$activeSt = $food_con->prepare(
|
||||
"SELECT COUNT(*) AS c FROM food_orders
|
||||
WHERE courier_id=? AND status IN ('courier_assigned','picked_up')"
|
||||
);
|
||||
$activeSt->execute([$food_courier_id]);
|
||||
$activeCount = (int)($activeSt->fetch()['c'] ?? 0);
|
||||
|
||||
$todaySt = $food_con->prepare(
|
||||
"SELECT COUNT(*) AS orders_count, COALESCE(SUM(delivery_fee),0) AS earnings
|
||||
FROM food_orders
|
||||
WHERE courier_id=? AND status='delivered' AND DATE(delivered_at)=CURDATE()"
|
||||
);
|
||||
$todaySt->execute([$food_courier_id]);
|
||||
$today = $todaySt->fetch();
|
||||
|
||||
jsonSuccess([
|
||||
'delivery_mode_enabled' => $enabled,
|
||||
'active_orders_count' => $activeCount,
|
||||
'today_orders_count' => (int)$today['orders_count'],
|
||||
'today_earnings' => (int)$today['earnings'],
|
||||
]);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// food/courier/toggle_availability.php — تفعيل/إيقاف "وضع التوصيل" للسائق
|
||||
// السائق لا يظهر لعروض التوصيل إلا إذا كان أيضاً متاحاً فعلياً في geo:drivers:available
|
||||
require_once __DIR__ . '/../connect_courier.php';
|
||||
|
||||
$enable = filterRequest('enabled', 'bool');
|
||||
if ($enable === null) jsonError('enabled (true|false) is required');
|
||||
|
||||
foodCourierOptIn($food_courier_id, $enable);
|
||||
|
||||
jsonSuccess(['courier_id' => $food_courier_id, 'delivery_mode_enabled' => $enable]);
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
// food/cron_order_timeouts.php — يُشغَّل كل دقيقة من crontab المضيف (مثل cron_* في transit)
|
||||
// 1) عروض توصيل معروضة أكثر من 20 ثانية بلا رد → timed_out + إعادة العرض لمرشح آخر
|
||||
// 2) طلبات pending أكثر من 5 دقائق بلا رد المطعم → إلغاء نظامي + استرجاع فوري
|
||||
|
||||
require_once __DIR__ . '/../core/bootstrap.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
|
||||
try { $food_con = Database::get('food'); }
|
||||
catch (Exception $e) { error_log('[FOOD][CRON] DB unavailable: ' . $e->getMessage()); exit(1); }
|
||||
|
||||
// ── 1) عروض منتهية المهلة ──
|
||||
$expiredSt = $food_con->query(
|
||||
"SELECT id, order_id, courier_id FROM food_courier_assignments
|
||||
WHERE status='offered' AND offered_at < (NOW() - INTERVAL 20 SECOND)"
|
||||
);
|
||||
foreach ($expiredSt->fetchAll() as $assignment) {
|
||||
$food_con->prepare("UPDATE food_courier_assignments SET status='timed_out', responded_at=NOW() WHERE id=?")
|
||||
->execute([$assignment['id']]);
|
||||
|
||||
$orderSt = $food_con->prepare("SELECT merchant_id FROM food_orders WHERE id=? AND status='ready'");
|
||||
$orderSt->execute([$assignment['order_id']]);
|
||||
$order = $orderSt->fetch();
|
||||
if (!$order) continue;
|
||||
|
||||
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
|
||||
$merchantSt->execute([$order['merchant_id']]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
if (!$merchant) continue;
|
||||
|
||||
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
|
||||
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
|
||||
$priorSt->execute([$assignment['order_id']]);
|
||||
$prior = array_column($priorSt->fetchAll(), 'courier_id');
|
||||
|
||||
foreach ($candidates as $candidateId) {
|
||||
if (in_array($candidateId, $prior, true)) continue;
|
||||
foodOfferOrderToCourier((int)$assignment['order_id'], $candidateId);
|
||||
error_log("[FOOD][CRON] Re-offered order {$assignment['order_id']} to courier {$candidateId} after timeout");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2) طلبات pending تجاوزت مهلة رد المطعم ──
|
||||
$stalePendingSt = $food_con->query(
|
||||
"SELECT id, passenger_id, payment_method, grand_total FROM food_orders
|
||||
WHERE status='pending' AND created_at < (NOW() - INTERVAL 5 MINUTE)"
|
||||
);
|
||||
foreach ($stalePendingSt->fetchAll() as $order) {
|
||||
food_transition_status((int)$order['id'], 'cancelled_system', 'system', 'cron', 'Merchant did not respond within timeout');
|
||||
|
||||
if ($order['payment_method'] === 'wallet') {
|
||||
$refunded = foodWalletMove(
|
||||
(string)$order['passenger_id'], (int)$order['grand_total'], 'add',
|
||||
"food-refund-{$order['id']}", "Food order #{$order['id']} auto-cancelled (merchant timeout)"
|
||||
);
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
|
||||
)->execute([$order['id'], (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
|
||||
|
||||
if (!$refunded) error_log("[FOOD][CRON] refund FAILED for auto-cancelled order {$order['id']} — needs manual reconciliation");
|
||||
}
|
||||
}
|
||||
|
||||
echo "food cron_order_timeouts done\n";
|
||||
@@ -0,0 +1,617 @@
|
||||
<?php
|
||||
// ============================================================
|
||||
// food/functions.php — دوال خاصة بوحدة طلبات الطعام فقط
|
||||
//
|
||||
// ما لا يوجد هنا (يُستخدم مباشرة من النظام الأصلي بعد bootstrap):
|
||||
// • filterRequest() ← core/helpers.php
|
||||
// • jsonSuccess() / jsonError() ← core/helpers.php
|
||||
// • appLog() / securityLog() ← core/helpers.php
|
||||
// • $redis ← مهيَّأ في bootstrap.php
|
||||
// ============================================================
|
||||
|
||||
require_once __DIR__ . '/../core/Services/FcmService.php';
|
||||
|
||||
// ── حقول مطلوبة (غلاف رفيع يستخدم filterRequest + jsonError) ──
|
||||
function requireFoodFields(array $fields): void
|
||||
{
|
||||
foreach ($fields as $f) {
|
||||
if (filterRequest($f) === null) {
|
||||
jsonError("Missing required field: $f", 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ملكية المطعم — يتحقق أن merchant_user يخص هذا المطعم فعلاً ──
|
||||
function foodAssertMerchantOwnership(int $merchantUserId, int $merchantId): void
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare("SELECT id FROM food_merchant_users WHERE id=? AND merchant_id=? AND is_active=1 LIMIT 1");
|
||||
$st->execute([$merchantUserId, $merchantId]);
|
||||
if (!$st->fetch()) jsonError('Forbidden', 403);
|
||||
}
|
||||
|
||||
// ── ملكية الطلب — كل نقطة تأخذ order_id يجب أن تتحقق أن الفاعل يملكه ──
|
||||
// $actorType: customer | merchant | courier
|
||||
function foodAssertOrderOwnership(int $orderId, string $actorType, string $actorId): array
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare("SELECT * FROM food_orders WHERE id=? LIMIT 1");
|
||||
$st->execute([$orderId]);
|
||||
$order = $st->fetch();
|
||||
if (!$order) jsonError('Order not found', 404);
|
||||
|
||||
$ok = match ($actorType) {
|
||||
'customer' => (string)$order['passenger_id'] === $actorId,
|
||||
'courier' => (string)$order['courier_id'] === $actorId,
|
||||
'merchant' => (string)$order['merchant_id'] === $actorId,
|
||||
default => false,
|
||||
};
|
||||
if (!$ok) {
|
||||
appLog("[FOOD][IDOR] actor_type=$actorType actor_id=$actorId tried order_id=$orderId", 'WARNING');
|
||||
jsonError('Forbidden', 403);
|
||||
}
|
||||
return $order;
|
||||
}
|
||||
|
||||
// ── آلة الحالة — كل انتقال يمر من هنا فقط، لا UPDATE مبعثر ──
|
||||
const FOOD_STATUS_TRANSITIONS = [
|
||||
'pending' => ['merchant_accepted', 'rejected', 'cancelled_by_customer', 'cancelled_system'],
|
||||
'merchant_accepted' => ['preparing', 'cancelled_by_merchant', 'cancelled_system'],
|
||||
'preparing' => ['ready', 'cancelled_by_merchant', 'cancelled_system'],
|
||||
'ready' => ['courier_assigned', 'cancelled_system'],
|
||||
'courier_assigned' => ['picked_up', 'cancelled_system'],
|
||||
'picked_up' => ['delivered'],
|
||||
'delivered' => [],
|
||||
'rejected' => [],
|
||||
'cancelled_by_customer' => [],
|
||||
'cancelled_by_merchant' => [],
|
||||
'cancelled_system' => [],
|
||||
];
|
||||
|
||||
function food_transition_status(int $orderId, string $toStatus, string $actorType, string $actorId, ?string $note = null): void
|
||||
{
|
||||
$con = Database::get('food');
|
||||
|
||||
$con->beginTransaction();
|
||||
try {
|
||||
$st = $con->prepare("SELECT status FROM food_orders WHERE id=? FOR UPDATE");
|
||||
$st->execute([$orderId]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) { $con->rollBack(); jsonError('Order not found', 404); }
|
||||
|
||||
$fromStatus = $row['status'];
|
||||
$allowed = FOOD_STATUS_TRANSITIONS[$fromStatus] ?? [];
|
||||
if (!in_array($toStatus, $allowed, true)) {
|
||||
$con->rollBack();
|
||||
jsonError("Invalid status transition: $fromStatus -> $toStatus", 409);
|
||||
}
|
||||
|
||||
$timestampColumn = match ($toStatus) {
|
||||
'merchant_accepted' => 'merchant_accepted_at',
|
||||
'ready' => 'ready_at',
|
||||
'courier_assigned' => 'courier_assigned_at',
|
||||
'picked_up' => 'picked_up_at',
|
||||
'delivered' => 'delivered_at',
|
||||
default => str_starts_with($toStatus, 'cancelled') || $toStatus === 'rejected' ? 'cancelled_at' : null,
|
||||
};
|
||||
|
||||
$sql = "UPDATE food_orders SET status=?" . ($timestampColumn ? ", {$timestampColumn}=NOW()" : '') . " WHERE id=?";
|
||||
$params = [$toStatus, $orderId];
|
||||
$con->prepare($sql)->execute($params);
|
||||
|
||||
$con->prepare(
|
||||
"INSERT INTO food_order_status_log (order_id, from_status, to_status, actor_type, actor_id, note)
|
||||
VALUES (?,?,?,?,?,?)"
|
||||
)->execute([$orderId, $fromStatus, $toStatus, $actorType, $actorId, $note]);
|
||||
|
||||
$con->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($con->inTransaction()) $con->rollBack();
|
||||
appLog('[FOOD][STATUS] ' . $e->getMessage(), 'ERROR');
|
||||
throw $e;
|
||||
}
|
||||
|
||||
foodNotifyOrderEvent($orderId, $toStatus);
|
||||
}
|
||||
|
||||
// ── إشعارات — سوكيت (internal HTTP push، نفس نمط passenger_socket) + FCM ──
|
||||
function foodNotifyOrderEvent(int $orderId, string $status): void
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare("SELECT passenger_id, merchant_id, courier_id FROM food_orders WHERE id=?");
|
||||
$st->execute([$orderId]);
|
||||
$order = $st->fetch();
|
||||
if (!$order) return;
|
||||
|
||||
foodPushToSocket('order_status_update', [
|
||||
'order_id' => $orderId,
|
||||
'status' => $status,
|
||||
'passenger_id' => (string)$order['passenger_id'],
|
||||
'merchant_id' => (int)$order['merchant_id'],
|
||||
'courier_id' => $order['courier_id'] ? (string)$order['courier_id'] : null,
|
||||
]);
|
||||
|
||||
$titles = [
|
||||
'merchant_accepted' => 'المطعم قبل طلبك',
|
||||
'preparing' => 'جاري تحضير طلبك',
|
||||
'ready' => 'طلبك جاهز، بانتظار السائق',
|
||||
'courier_assigned' => 'تم تعيين سائق لتوصيل طلبك',
|
||||
'picked_up' => 'السائق استلم طلبك وفي الطريق إليك',
|
||||
'delivered' => 'تم تسليم طلبك، بالهنا والشفا',
|
||||
'rejected' => 'اعتذر المطعم عن تنفيذ طلبك',
|
||||
];
|
||||
if (isset($titles[$status])) {
|
||||
foodSendNotificationToPassenger((string)$order['passenger_id'], $titles[$status], '', ['type' => 'food_order_' . $status, 'order_id' => (string)$orderId]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── نداء HTTP داخلي لـ socket_food — نفس نمط broadcast_bus_location في transit ──
|
||||
function foodPushToSocket(string $action, array $payload): void
|
||||
{
|
||||
$url = getenv('FOOD_SOCKET_URL') ?: 'http://socket_food:4041';
|
||||
$key = getInternalSocketKey();
|
||||
if (!$key) { appLog('[FOOD][SOCKET] INTERNAL_SOCKET_KEY missing — skip push', 'WARNING'); return; }
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query(['action' => $action, 'payload' => json_encode($payload)]),
|
||||
CURLOPT_HTTPHEADER => ["X-Internal-Key: $key"],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 2,
|
||||
CURLOPT_CONNECTTIMEOUT => 1,
|
||||
]);
|
||||
$result = curl_exec($ch);
|
||||
if ($result === false) {
|
||||
appLog('[FOOD][SOCKET] push failed: ' . curl_error($ch), 'WARNING');
|
||||
}
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
function foodSendNotificationToPassenger(string $passengerId, string $title, string $body, array $data = []): void
|
||||
{
|
||||
global $redis;
|
||||
if (!$passengerId) return;
|
||||
$fcm = new FcmService($redis);
|
||||
$result = $fcm->sendToTopic('passenger_' . $passengerId, $title, $body, $data);
|
||||
if ($result['status'] !== 'success') {
|
||||
appLog("[FOOD][FCM] push failed for passenger {$passengerId}: " . json_encode($result), 'WARNING');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// قناة اتصال مقنّعة بين السائق والزبون (WebRTC — بلا أرقام هواتف)
|
||||
// ------------------------------------------------------------
|
||||
// لا نكشف رقم أي طرف للآخر إطلاقاً: خادم الإشارات (Node) يفتح جلسة
|
||||
// بمعرّف عشوائي قصير العمر، والطرفان ينضمّان إليها بـ session_id فقط.
|
||||
// نستعمل نفس بنية مكالمات الرحلات (VOICE_CALL_SERVER_URL) لكن بمرجع
|
||||
// جلسة مُسمّى food_{order_id} حتى تبقى سجلات الطعام مميّزة عن الرحلات.
|
||||
// ============================================================
|
||||
|
||||
// حد إساءة الاستخدام: عدد محاولات فتح جلسة لكل طلب/طرف خلال ساعتين
|
||||
const FOOD_CALL_MAX_PER_ORDER = 10;
|
||||
|
||||
function foodCallQuotaExceeded(int $orderId, string $callerRole): bool
|
||||
{
|
||||
global $redis;
|
||||
if (!$redis) return false;
|
||||
|
||||
$key = "food:call_quota:{$orderId}:{$callerRole}";
|
||||
$count = (int)$redis->incr($key);
|
||||
if ($count === 1) $redis->expire($key, 7200);
|
||||
|
||||
if ($count > FOOD_CALL_MAX_PER_ORDER) {
|
||||
appLog("[FOOD][CALL] quota exceeded order=$orderId role=$callerRole", 'WARNING');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* يفتح جلسة مكالمة على خادم الإشارات ويعيد session_id.
|
||||
* يعيد null عند أي فشل — المُنادي هو من يقرر رسالة الخطأ للمستخدم.
|
||||
*/
|
||||
function foodCreateCallSession(int $orderId, string $courierId, string $passengerId): ?array
|
||||
{
|
||||
$url = (getenv('VOICE_CALL_SERVER_URL') ?: 'https://calls.intaleqapp.com') . '/sessions';
|
||||
$apiKey = getenv('VOICE_CALL_API_KEY') ?: '';
|
||||
if (!$apiKey) {
|
||||
appLog('[FOOD][CALL] VOICE_CALL_API_KEY missing — cannot create session', 'ERROR');
|
||||
return null;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
// خادم الإشارات يعامل ride_id كمعرّف نصّي مبهم — نُميّز طلبات الطعام
|
||||
'ride_id' => 'food_' . $orderId,
|
||||
'driver_id' => $courierId,
|
||||
'passenger_id' => $passengerId,
|
||||
]),
|
||||
CURLOPT_HTTPHEADER => ["x-api-key: $apiKey", 'Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
]);
|
||||
|
||||
$result = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
appLog("[FOOD][CALL] signaling server failed (HTTP $httpCode): $result", 'ERROR');
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode((string)$result, true);
|
||||
if (!isset($data['session_id'])) {
|
||||
appLog('[FOOD][CALL] invalid response schema from signaling server', 'ERROR');
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'session_id' => (string)$data['session_id'],
|
||||
'expires_in' => (int)($data['expires_in'] ?? 60),
|
||||
];
|
||||
}
|
||||
|
||||
// المكالمة مسموحة فقط داخل النافذة التشغيلية للطلب — بعد التسليم تُقفل القناة
|
||||
function foodOrderAllowsCall(array $order): bool
|
||||
{
|
||||
return in_array($order['status'], ['courier_assigned', 'picked_up'], true)
|
||||
&& !empty($order['courier_id']);
|
||||
}
|
||||
|
||||
// ── Session مطعم (هاتف+كلمة مرور — مستقلة عن JWT، مثل transit) ──
|
||||
|
||||
function foodCreateMerchantSession(int $merchantUserId, int $merchantId): string
|
||||
{
|
||||
global $redis;
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$hash = hash('sha256', $token);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + 86400);
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
|
||||
$con = Database::get('food');
|
||||
$con->prepare(
|
||||
"INSERT INTO food_merchant_sessions (merchant_user_id, merchant_id, token_hash, ip, user_agent, expires_at)
|
||||
VALUES (?,?,?,?,?,?)"
|
||||
)->execute([$merchantUserId, $merchantId, $hash, $ip, $ua, $expiresAt]);
|
||||
|
||||
if ($redis) {
|
||||
$redis->setEx(
|
||||
"food:merchant_session:{$hash}",
|
||||
86400,
|
||||
json_encode(['merchant_user_id' => $merchantUserId, 'merchant_id' => $merchantId])
|
||||
);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
function foodAuthMerchant(): array
|
||||
{
|
||||
global $redis;
|
||||
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_FOOD_MERCHANT_TOKEN'] ?? '';
|
||||
$token = str_replace('Bearer ', '', $header);
|
||||
if (!$token) jsonError('Missing merchant session token', 401);
|
||||
|
||||
$hash = hash('sha256', $token);
|
||||
|
||||
if ($redis) {
|
||||
$val = $redis->get("food:merchant_session:{$hash}");
|
||||
if ($val) {
|
||||
$data = json_decode($val, true);
|
||||
if ($data) return $data;
|
||||
}
|
||||
jsonError('Session expired or invalid', 401);
|
||||
}
|
||||
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare(
|
||||
"SELECT merchant_user_id, merchant_id FROM food_merchant_sessions
|
||||
WHERE token_hash=? AND expires_at > NOW() LIMIT 1"
|
||||
);
|
||||
$st->execute([$hash]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) jsonError('Session expired or invalid', 401);
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ── هل المطعم مفتوح الآن؟ يحترم الإغلاق/الفتح اليدوي أولاً، ثم working_hours ──
|
||||
function foodIsMerchantOpen(array $merchant): bool
|
||||
{
|
||||
if (isset($merchant['is_open_override']) && $merchant['is_open_override'] !== null) {
|
||||
return (bool)$merchant['is_open_override'];
|
||||
}
|
||||
|
||||
$hours = $merchant['working_hours'] ?? null;
|
||||
if (!$hours) return true; // بلا جدول محدد = مفتوح افتراضياً
|
||||
|
||||
$hours = is_string($hours) ? json_decode($hours, true) : $hours;
|
||||
if (!$hours) return true;
|
||||
|
||||
$dayKeys = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
$today = $dayKeys[(int)date('w')];
|
||||
$ranges = $hours[$today] ?? [];
|
||||
if (!$ranges) return false;
|
||||
|
||||
$now = date('H:i');
|
||||
foreach ($ranges as $range) {
|
||||
if (!isset($range[0], $range[1])) continue;
|
||||
if ($now >= $range[0] && $now <= $range[1]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// المحفظة — S2S عبر سيرفر المحفظة القُطري (نفس عقد backend/api/payments/initiate_prime.php)
|
||||
//
|
||||
// ⚠️ ملاحظة صريحة: سيرفر المحفظة الفعلي لا يعرض API حجز-ثم-التقاط (hold/capture)
|
||||
// حقيقياً — العقد المتاح هو خصم/إضافة فوري فقط (action=subtract|add). لذلك
|
||||
// "الحجز" هنا هو **خصم فوري عند الإنشاء + استرجاع كامل عند الرفض/الإلغاء**،
|
||||
// وليس حجزاً بالمعنى المصرفي. إن أُضيف hold حقيقي لاحقاً في سيرفر المحفظة
|
||||
// فهذه الدالة أول مكان يُعدَّل.
|
||||
//
|
||||
// ⚠️ ملاحظة ثانية: عامل تحويل "أصغر وحدة نقدية" (fils/qirsh) إلى المبلغ
|
||||
// العشري الذي يتوقعه سيرفر المحفظة (كما في initiate_prime.php: 3.00 JOD)
|
||||
// غير مؤكد لكل دولة — FOOD_CURRENCY_DIVISOR افتراضي 1000 (مثل JOD/fils).
|
||||
// يجب تأكيده مع فريق المحفظة قبل أي تشغيل فعلي بمال حقيقي.
|
||||
// ============================================================
|
||||
|
||||
function foodWalletServerUrl(): string
|
||||
{
|
||||
// bootstrap.php يعرّف الثابت GLOBAL_COUNTRY فقط (لا putenv) — استخدم الثابت لا getenv
|
||||
$country = strtolower(defined('GLOBAL_COUNTRY') ? GLOBAL_COUNTRY : (getenv('GLOBAL_COUNTRY') ?: 'jordan'));
|
||||
return match ($country) {
|
||||
'egypt' => getenv('WALLET_SERVER_EGYPT') ?: 'https://wallet-egypt.siromove.com',
|
||||
'syria' => getenv('WALLET_SERVER_SYRIA') ?: 'https://wallet-syria.siromove.com',
|
||||
default => getenv('WALLET_SERVER_JORDAN') ?: 'https://walletintaleq.intaleq.xyz',
|
||||
};
|
||||
}
|
||||
|
||||
function foodSmallestUnitToDecimal(int $amount): float
|
||||
{
|
||||
$divisor = (float)(getenv('FOOD_CURRENCY_DIVISOR') ?: 1000);
|
||||
return round($amount / $divisor, 3);
|
||||
}
|
||||
|
||||
function foodWalletGetBalance(string $userId, string $userType = 'passenger'): ?float
|
||||
{
|
||||
$s2sKey = getenv('S2S_SHARED_KEY');
|
||||
if (!$s2sKey) { appLog('[FOOD][WALLET] S2S_SHARED_KEY missing', 'ERROR'); return null; }
|
||||
|
||||
$url = foodWalletServerUrl() . '/v2/main/ride/passengerWallet/getWalletByPassenger.php';
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query(['passenger_id' => $userId]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', "X-S2S-Api-Key: $s2sKey"],
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (!$raw || $code !== 200) { appLog("[FOOD][WALLET] balance fetch failed HTTP $code", 'ERROR'); return null; }
|
||||
$data = json_decode($raw, true);
|
||||
$bal = $data['message'][0]['total'] ?? $data['total'] ?? null;
|
||||
return $bal !== null ? (float)$bal : null;
|
||||
}
|
||||
|
||||
// $amountSmallestUnit موجب دائماً؛ $action = subtract (خصم) أو add (إضافة/استرجاع)
|
||||
function foodWalletMove(string $userId, int $amountSmallestUnit, string $action, string $paymentId, string $reason, string $userType = 'passenger'): bool
|
||||
{
|
||||
$s2sKey = getenv('S2S_SHARED_KEY');
|
||||
if (!$s2sKey) { appLog('[FOOD][WALLET] S2S_SHARED_KEY missing', 'ERROR'); return false; }
|
||||
|
||||
$decimalAmount = foodSmallestUnitToDecimal($amountSmallestUnit);
|
||||
$signedAmount = $action === 'subtract' ? -1 * $decimalAmount : $decimalAmount;
|
||||
|
||||
$url = foodWalletServerUrl() . '/v2/main/ride/payment/add.php';
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query([
|
||||
'user_id' => $userId,
|
||||
'user_type' => $userType,
|
||||
'amount' => $signedAmount,
|
||||
'action' => $action,
|
||||
'paymentID' => $paymentId,
|
||||
'paymentMethod' => 'food-order',
|
||||
'reason' => $reason,
|
||||
]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 15,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', "X-S2S-Api-Key: $s2sKey"],
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err || $code !== 200) {
|
||||
appLog("[FOOD][WALLET] move failed user=$userId action=$action HTTP $code err=$err", 'ERROR');
|
||||
return false;
|
||||
}
|
||||
$res = json_decode($raw, true);
|
||||
$ok = ($res['status'] ?? '') === 'success';
|
||||
if (!$ok) appLog("[FOOD][WALLET] move rejected by wallet server: $raw", 'ERROR');
|
||||
return $ok;
|
||||
}
|
||||
|
||||
// ── توقيع عرض السعر (السلة) — يثبّت المجموع 10 دقائق، الخادم لا يثق بسعر العميل ──
|
||||
function foodSignQuote(array $quote): string
|
||||
{
|
||||
$secret = getenv('SECRET_KEY_HMAC') ?: '';
|
||||
$quote['expires_at'] = time() + 600;
|
||||
$payload = base64_encode(json_encode($quote));
|
||||
$sig = hash_hmac('sha256', $payload, $secret);
|
||||
return $payload . '.' . $sig;
|
||||
}
|
||||
|
||||
function foodVerifyQuote(string $token): array
|
||||
{
|
||||
$secret = getenv('SECRET_KEY_HMAC') ?: '';
|
||||
$parts = explode('.', $token, 2);
|
||||
if (count($parts) !== 2) jsonError('Invalid quote token', 400);
|
||||
[$payload, $sig] = $parts;
|
||||
|
||||
$expected = hash_hmac('sha256', $payload, $secret);
|
||||
if (!hash_equals($expected, $sig)) jsonError('Quote token signature mismatch', 400);
|
||||
|
||||
$quote = json_decode(base64_decode($payload), true);
|
||||
if (!$quote) jsonError('Invalid quote token', 400);
|
||||
if (($quote['expires_at'] ?? 0) < time()) jsonError('Quote expired — refresh your cart', 409);
|
||||
|
||||
return $quote;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// أسطول التوصيل — نفس السائقين مع تمييز اختياري بالدور (can_deliver)
|
||||
//
|
||||
// ⚠️ لا نكتب على أي مفتاح Redis يديره loction_server/driver_socket.php —
|
||||
// التعديل على تلك العملية الدائمة الحية خارج نطاق هذه الوحدة ومخاطرته
|
||||
// عالية (تخدم كل مطابقة الرحلات). بدلاً من ذلك: سائق يفعّل "وضع التوصيل"
|
||||
// من تطبيقه فيُضاف إلى SET مستقلة `food:couriers:opted_in`، ونتقاطع مع
|
||||
// `geo:drivers:available` (يقرأها هذا الملف فقط — لا يكتب عليها أبداً)
|
||||
// لإيجاد سائقين متاحين للرحلات فعلياً وأيضاً منضمّين لوضع التوصيل.
|
||||
// ============================================================
|
||||
|
||||
function foodCourierOptIn(string $courierId, bool $enable): void
|
||||
{
|
||||
global $redis;
|
||||
if (!$redis) return;
|
||||
if ($enable) {
|
||||
$redis->sAdd('food:couriers:opted_in', $courierId);
|
||||
} else {
|
||||
$redis->sRem('food:couriers:opted_in', $courierId);
|
||||
}
|
||||
}
|
||||
|
||||
function foodFindNearbyCouriers(float $lat, float $lng, float $radiusKm = 5, int $limit = 10): array
|
||||
{
|
||||
global $redisLocation, $redis;
|
||||
if (!$redisLocation || !$redis) return [];
|
||||
|
||||
$nearby = $redisLocation->georadius(
|
||||
'geo:drivers:available', $lng, $lat, $radiusKm, 'km', ['COUNT' => $limit, 'SORT' => 'ASC']
|
||||
);
|
||||
if (!$nearby) return [];
|
||||
|
||||
$optedIn = $redis->sMembers('food:couriers:opted_in');
|
||||
if (!$optedIn) return [];
|
||||
|
||||
$candidates = array_values(array_intersect($nearby, $optedIn));
|
||||
if (!$candidates) return [];
|
||||
|
||||
// مهمة واحدة في الوقت الواحد: التطبيق يُعلن السائق مشغولاً في نظام الرحلات
|
||||
// فور إسناد طلب له، فيخرج من geo:drivers:available وحده. لكن ذلك يعتمد على
|
||||
// وصول تحديث موقع، فقد يتأخر ثوانٍ. هذا الفحص هو الضمانة القاطعة: لا يُعرض
|
||||
// طلب ثانٍ على سائق يحمل طلباً نشطاً مهما تأخّر تحديث المجموعة.
|
||||
$placeholders = implode(',', array_fill(0, count($candidates), '?'));
|
||||
$busySt = Database::get('food')->prepare(
|
||||
"SELECT DISTINCT courier_id FROM food_orders
|
||||
WHERE courier_id IN ($placeholders) AND status IN ('courier_assigned','picked_up')"
|
||||
);
|
||||
$busySt->execute($candidates);
|
||||
$busy = array_column($busySt->fetchAll(), 'courier_id');
|
||||
|
||||
return array_values(array_diff($candidates, $busy));
|
||||
}
|
||||
|
||||
function foodOfferOrderToCourier(int $orderId, string $courierId): void
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$con->prepare(
|
||||
"INSERT INTO food_courier_assignments (order_id, courier_id, status) VALUES (?,?,'offered')"
|
||||
)->execute([$orderId, $courierId]);
|
||||
|
||||
// العرض يُدفع كاملاً عبر السوكيت: شاشة العرض عند السائق تُبنى من هذه الحمولة
|
||||
// مباشرة بلا نداء HTTP إضافي (مهلة العرض 20 ثانية لا تحتمل round-trip زائد).
|
||||
$offer = foodBuildCourierOfferPayload($orderId);
|
||||
|
||||
// ملاحظة: لا FCM هنا عمداً — توكن جهاز السائق في جدول driverToken على main DB،
|
||||
// وممنوع Database::get('main') داخل backend/food/ (نفس قاعدة transit). الإشعار
|
||||
// اللحظي يمر فقط عبر socket_food (السائق متصل بسوكيته أثناء وضع التوصيل)،
|
||||
// والتطبيق يعتمد أيضاً على courier/pending_offers.php كـ polling fallback عند الانقطاع.
|
||||
foodPushToSocket('courier_offer', array_merge(
|
||||
['order_id' => $orderId, 'courier_id' => $courierId, 'offer_ttl_seconds' => 20],
|
||||
$offer
|
||||
));
|
||||
}
|
||||
|
||||
// حمولة عرض التوصيل — مصدر واحد يستخدمه السوكيت و pending_offers.php معاً
|
||||
// حتى لا تختلف الحقول بين المسار اللحظي ومسار الاحتياط.
|
||||
function foodBuildCourierOfferPayload(int $orderId): array
|
||||
{
|
||||
$con = Database::get('food');
|
||||
$st = $con->prepare(
|
||||
"SELECT o.id, o.merchant_id, o.delivery_fee, o.grand_total, o.payment_method,
|
||||
o.delivery_lat, o.delivery_lng, o.items_total, o.created_at,
|
||||
m.name_ar AS merchant_name_ar, m.address AS merchant_address,
|
||||
m.latitude AS merchant_lat, m.longitude AS merchant_lng,
|
||||
(SELECT COALESCE(SUM(quantity),0) FROM food_order_items WHERE order_id=o.id) AS items_count
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.id = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$orderId]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) return [];
|
||||
|
||||
// العنوان النصّي للزبون لا يُرسل في العرض — يظهر فقط بعد القبول (active.php).
|
||||
// نرسل الإحداثيات فقط لحساب المسافة/الاتجاه في شاشة العرض.
|
||||
return [
|
||||
'merchant_id' => (int)$row['merchant_id'],
|
||||
'merchant_name_ar' => $row['merchant_name_ar'],
|
||||
'merchant_address' => $row['merchant_address'],
|
||||
'merchant_lat' => (float)$row['merchant_lat'],
|
||||
'merchant_lng' => (float)$row['merchant_lng'],
|
||||
'delivery_lat' => (float)$row['delivery_lat'],
|
||||
'delivery_lng' => (float)$row['delivery_lng'],
|
||||
'delivery_fee' => (int)$row['delivery_fee'],
|
||||
'items_count' => (int)$row['items_count'],
|
||||
'payment_method' => $row['payment_method'],
|
||||
// نقداً: السائق سيحصّل هذا المبلغ من الزبون — معلومة قرار أساسية قبل القبول
|
||||
'cash_to_collect' => $row['payment_method'] === 'cash' ? (int)$row['grand_total'] : 0,
|
||||
'order_created_at' => $row['created_at'],
|
||||
];
|
||||
}
|
||||
|
||||
// SET NX EX 20 — القابل الأول فقط يفوز، ذرّياً (يمنع سباق القبول)
|
||||
function foodLockOrderForCourier(int $orderId, string $courierId): bool
|
||||
{
|
||||
global $redis;
|
||||
if (!$redis) return false;
|
||||
return (bool)$redis->set("food:order:{$orderId}:lock", $courierId, ['NX', 'EX' => 20]);
|
||||
}
|
||||
|
||||
function foodOrderLockOwner(int $orderId): ?string
|
||||
{
|
||||
global $redis;
|
||||
if (!$redis) return null;
|
||||
$v = $redis->get("food:order:{$orderId}:lock");
|
||||
return $v ?: null;
|
||||
}
|
||||
|
||||
// ── حساب مبالغ الطلب من الخادم — العميل لا يُملي السعر أبداً ──
|
||||
function foodComputeItemsTotal(array $lines): int
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($lines as $line) {
|
||||
$total += (int)$line['line_total'];
|
||||
}
|
||||
return $total;
|
||||
}
|
||||
|
||||
function foodComputeCommission(int $itemsTotal, float $commissionPercent): int
|
||||
{
|
||||
return (int) round($itemsTotal * $commissionPercent / 100);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
// food/merchant/browse.php — تصفح المطاعم النشطة في مدينة الزبون
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$city = filterRequest('city');
|
||||
if (!$city) jsonError('city is required');
|
||||
|
||||
$category = filterRequest('category');
|
||||
|
||||
$sql = "SELECT id, name_ar, name_en, logo_url, cover_url, city, category,
|
||||
min_order_amount, avg_prep_minutes, rating_avg, rating_count,
|
||||
is_open_override, working_hours
|
||||
FROM food_merchants WHERE city=? AND status='active'";
|
||||
$params = [$city];
|
||||
|
||||
if ($category) {
|
||||
$sql .= " AND category=?";
|
||||
$params[] = $category;
|
||||
}
|
||||
$sql .= " ORDER BY rating_avg DESC, rating_count DESC";
|
||||
|
||||
$st = $food_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
$merchants = $st->fetchAll();
|
||||
|
||||
foreach ($merchants as &$m) {
|
||||
$m['is_open'] = foodIsMerchantOpen($m);
|
||||
unset($m['working_hours'], $m['is_open_override']);
|
||||
}
|
||||
unset($m);
|
||||
|
||||
jsonSuccess(['merchants' => $merchants]);
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
// food/merchant/details.php — تفاصيل مطعم واحد + قائمته الكاملة
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$merchantId = filterRequest('merchant_id', 'int');
|
||||
if (!$merchantId) jsonError('merchant_id is required');
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT id, name_ar, name_en, logo_url, cover_url, description_ar, city, address,
|
||||
latitude, longitude, category, min_order_amount, avg_prep_minutes,
|
||||
rating_avg, rating_count, is_open_override, working_hours
|
||||
FROM food_merchants WHERE id=? AND status='active' LIMIT 1"
|
||||
);
|
||||
$st->execute([$merchantId]);
|
||||
$merchant = $st->fetch();
|
||||
if (!$merchant) jsonError('Merchant not found', 404);
|
||||
|
||||
$merchant['is_open'] = foodIsMerchantOpen($merchant);
|
||||
unset($merchant['working_hours'], $merchant['is_open_override']);
|
||||
|
||||
$catSt = $food_con->prepare(
|
||||
"SELECT id, name_ar, name_en, sort_order FROM food_menu_categories
|
||||
WHERE merchant_id=? AND is_active=1 ORDER BY sort_order ASC"
|
||||
);
|
||||
$catSt->execute([$merchantId]);
|
||||
$categories = $catSt->fetchAll();
|
||||
|
||||
$itemSt = $food_con->prepare(
|
||||
"SELECT id, category_id, name_ar, name_en, description_ar, image_url, price,
|
||||
is_available, prep_minutes, sort_order
|
||||
FROM food_menu_items WHERE merchant_id=? ORDER BY sort_order ASC"
|
||||
);
|
||||
$itemSt->execute([$merchantId]);
|
||||
$items = $itemSt->fetchAll();
|
||||
|
||||
if ($items) {
|
||||
$itemIds = implode(',', array_map('intval', array_column($items, 'id')));
|
||||
$optSt = $food_con->query(
|
||||
"SELECT id, item_id, group_name_ar, is_required, max_select, choices, sort_order
|
||||
FROM food_item_options WHERE item_id IN ($itemIds) ORDER BY sort_order ASC"
|
||||
);
|
||||
$options = $optSt ? $optSt->fetchAll() : [];
|
||||
$byItem = [];
|
||||
foreach ($options as $o) {
|
||||
$o['choices'] = json_decode($o['choices'], true);
|
||||
$byItem[$o['item_id']][] = $o;
|
||||
}
|
||||
foreach ($items as &$it) {
|
||||
$it['options'] = $byItem[$it['id']] ?? [];
|
||||
}
|
||||
unset($it);
|
||||
}
|
||||
|
||||
foreach ($categories as &$c) {
|
||||
$c['items'] = array_values(array_filter($items, fn($i) => (int)$i['category_id'] === (int)$c['id']));
|
||||
}
|
||||
unset($c);
|
||||
|
||||
jsonSuccess(['merchant' => $merchant, 'categories' => $categories]);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// food/merchant/search.php — بحث نصي في اسم المطعم أو أصنافه ضمن مدينة الزبون
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$city = filterRequest('city');
|
||||
$q = filterRequest('q');
|
||||
if (!$city) jsonError('city is required');
|
||||
if (!$q || mb_strlen($q) < 2) jsonError('q must be at least 2 characters');
|
||||
|
||||
$like = '%' . $q . '%';
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT DISTINCT m.id, m.name_ar, m.name_en, m.logo_url, m.city, m.category,
|
||||
m.rating_avg, m.rating_count
|
||||
FROM food_merchants m
|
||||
LEFT JOIN food_menu_items i ON i.merchant_id = m.id
|
||||
WHERE m.city = ? AND m.status='active'
|
||||
AND (m.name_ar LIKE ? OR m.name_en LIKE ? OR i.name_ar LIKE ? OR i.name_en LIKE ?)
|
||||
ORDER BY m.rating_avg DESC
|
||||
LIMIT 50"
|
||||
);
|
||||
$st->execute([$city, $like, $like, $like, $like]);
|
||||
|
||||
jsonSuccess(['merchants' => $st->fetchAll()]);
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
// food/merchant_auth/login.php — دخول لوحة المطعم (هاتف + كلمة مرور)
|
||||
require_once __DIR__ . '/../../core/bootstrap.php';
|
||||
require_once __DIR__ . '/../functions.php';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
|
||||
|
||||
$limiter = new RateLimiter($redis);
|
||||
$limiter->enforce(RateLimiter::identifier(), 'login');
|
||||
|
||||
requireFoodFields(['phone', 'password']);
|
||||
$phone = normalizePhone(filterRequest('phone'));
|
||||
$password = filterRequest('password');
|
||||
|
||||
try {
|
||||
$food_con = Database::get('food');
|
||||
} catch (Exception $e) {
|
||||
jsonError('Food service unavailable', 503);
|
||||
}
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT id, merchant_id, password_hash, is_active FROM food_merchant_users WHERE phone=? LIMIT 1"
|
||||
);
|
||||
$st->execute([$phone]);
|
||||
$user = $st->fetch();
|
||||
|
||||
if (!$user || !$user['is_active'] || !password_verify($password, $user['password_hash'])) {
|
||||
appLog("[FOOD][MERCHANT_LOGIN] failed attempt for phone hash " . hash('sha256', $phone), 'WARNING');
|
||||
jsonError('Invalid credentials', 401);
|
||||
}
|
||||
|
||||
$merchantSt = $food_con->prepare("SELECT status FROM food_merchants WHERE id=?");
|
||||
$merchantSt->execute([$user['merchant_id']]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
if (!$merchant || !in_array($merchant['status'], ['active', 'paused'], true)) {
|
||||
jsonError('Merchant account is not active', 403);
|
||||
}
|
||||
|
||||
$token = foodCreateMerchantSession((int)$user['id'], (int)$user['merchant_id']);
|
||||
|
||||
jsonSuccess(['session_token' => $token, 'merchant_id' => (int)$user['merchant_id']], 'Login successful');
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// food/merchant_ops/accept.php — المطعم يقبل الطلب
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
|
||||
food_transition_status($orderId, 'merchant_accepted', 'merchant', (string)$food_merchant_user_id);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'merchant_accepted']);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// food/merchant_ops/incoming.php — الطلبات الحالية للمطعم
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$statusFilter = filterRequest('status') ?: 'active';
|
||||
$allowed = ['active', 'pending', 'merchant_accepted', 'preparing', 'ready', 'history'];
|
||||
if (!in_array($statusFilter, $allowed, true)) $statusFilter = 'active';
|
||||
|
||||
if ($statusFilter === 'active') {
|
||||
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at
|
||||
FROM food_orders WHERE merchant_id=? AND status IN ('pending','merchant_accepted','preparing','ready','courier_assigned','picked_up')
|
||||
ORDER BY created_at ASC";
|
||||
$params = [$food_merchant_id];
|
||||
} elseif ($statusFilter === 'history') {
|
||||
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at, delivered_at
|
||||
FROM food_orders WHERE merchant_id=? AND status IN ('delivered','rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system')
|
||||
ORDER BY created_at DESC LIMIT 100";
|
||||
$params = [$food_merchant_id];
|
||||
} else {
|
||||
$sql = "SELECT id, passenger_id, status, items_total, delivery_fee, grand_total, created_at
|
||||
FROM food_orders WHERE merchant_id=? AND status=? ORDER BY created_at ASC";
|
||||
$params = [$food_merchant_id, $statusFilter];
|
||||
}
|
||||
|
||||
$st = $food_con->prepare($sql);
|
||||
$st->execute($params);
|
||||
$orders = $st->fetchAll();
|
||||
|
||||
if ($orders) {
|
||||
$ids = implode(',', array_map('intval', array_column($orders, 'id')));
|
||||
$itemsSt = $food_con->query("SELECT order_id, name_ar_snapshot, quantity FROM food_order_items WHERE order_id IN ($ids)");
|
||||
$allItems = $itemsSt ? $itemsSt->fetchAll() : [];
|
||||
foreach ($orders as &$o) {
|
||||
$o['items'] = array_values(array_filter($allItems, fn($i) => (int)$i['order_id'] === (int)$o['id']));
|
||||
}
|
||||
unset($o);
|
||||
}
|
||||
|
||||
jsonSuccess(['orders' => $orders]);
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
// food/merchant_ops/items_toggle.php — تفعيل/إيقاف صنف (نفد من المخزون مثلاً)
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$itemId = filterRequest('item_id', 'int');
|
||||
if (!$itemId) jsonError('item_id is required');
|
||||
|
||||
$st = $food_con->prepare("SELECT id, merchant_id, is_available FROM food_menu_items WHERE id=? LIMIT 1");
|
||||
$st->execute([$itemId]);
|
||||
$item = $st->fetch();
|
||||
if (!$item) jsonError('Item not found', 404);
|
||||
if ((int)$item['merchant_id'] !== $food_merchant_id) jsonError('Forbidden', 403);
|
||||
|
||||
$newAvailability = $item['is_available'] ? 0 : 1;
|
||||
$food_con->prepare("UPDATE food_menu_items SET is_available=? WHERE id=?")->execute([$newAvailability, $itemId]);
|
||||
|
||||
jsonSuccess(['item_id' => $itemId, 'is_available' => (bool)$newAvailability]);
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
// food/merchant_ops/preparing.php — المطعم بدأ التحضير
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
|
||||
food_transition_status($orderId, 'preparing', 'merchant', (string)$food_merchant_user_id);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'preparing']);
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// food/merchant_ops/ready.php — الطلب جاهز — يبدأ حلقة إسناد السائق
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
|
||||
food_transition_status($orderId, 'ready', 'merchant', (string)$food_merchant_user_id);
|
||||
|
||||
$merchantSt = $food_con->prepare("SELECT latitude, longitude FROM food_merchants WHERE id=?");
|
||||
$merchantSt->execute([$food_merchant_id]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
|
||||
$offered = false;
|
||||
if ($merchant) {
|
||||
$candidates = foodFindNearbyCouriers((float)$merchant['latitude'], (float)$merchant['longitude']);
|
||||
|
||||
// استبعاد من سبق عرض هذا الطلب عليه (رفض أو انتهت مهلته)
|
||||
$priorSt = $food_con->prepare("SELECT courier_id FROM food_courier_assignments WHERE order_id=?");
|
||||
$priorSt->execute([$orderId]);
|
||||
$prior = array_column($priorSt->fetchAll(), 'courier_id');
|
||||
|
||||
foreach ($candidates as $courierId) {
|
||||
if (in_array($courierId, $prior, true)) continue;
|
||||
foodOfferOrderToCourier($orderId, $courierId);
|
||||
$offered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$offered) {
|
||||
appLog("[FOOD][DELIVERY] No available courier found for order $orderId at ready-time", 'WARNING');
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'ready', 'courier_offer_sent' => $offered]);
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// food/merchant_ops/reject.php — المطعم يرفض الطلب — استرجاع فوري إن دُفع من المحفظة
|
||||
require_once __DIR__ . '/../connect_merchant.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$reason = filterRequest('reason');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'merchant', (string)$food_merchant_id);
|
||||
food_transition_status($orderId, 'rejected', 'merchant', (string)$food_merchant_user_id, $reason);
|
||||
|
||||
if ($order['payment_method'] === 'wallet') {
|
||||
$refunded = foodWalletMove(
|
||||
(string)$order['passenger_id'], (int)$order['grand_total'], 'add',
|
||||
"food-refund-{$orderId}", "Food order #{$orderId} rejected by merchant"
|
||||
);
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
|
||||
)->execute([$orderId, (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
|
||||
|
||||
if (!$refunded) appLog("[FOOD][MERCHANT][reject] refund FAILED for order $orderId — needs manual reconciliation", 'ERROR');
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'rejected']);
|
||||
@@ -0,0 +1,59 @@
|
||||
-- 2026_08_07_courier_settlement.sql — دورة تسوية أرباح سائقي التوصيل
|
||||
--
|
||||
-- قرار المالك: الأجرة **تتراكم وتُصرف دورياً مع مقاصّة دَين النقد**، لا
|
||||
-- تُصرف فوراً عند كل تسليم.
|
||||
--
|
||||
-- لماذا المقاصّة ضرورية: في الطلب النقدي يحصّل السائق grand_total كاملاً
|
||||
-- من الزبون، فيصير مديناً للمنصة بحصة المطعم والعمولة. صرف أجرته منفصلاً
|
||||
-- عن هذا الدَين يعني أن تدفع له بينما هو يحمل مالك.
|
||||
--
|
||||
-- الصافي = مجموع أجور التوصيل − مجموع ديون النقد
|
||||
--
|
||||
-- موجب → يُصرف لمحفظة السائق
|
||||
-- سالب → يبقى ديناً يُرحَّل للدورة التالية (لا نخصم من محفظته آلياً:
|
||||
-- قد تكون فارغة، والخصم القسري يفاجئه ويوقفه عن العمل)
|
||||
--
|
||||
-- ⚠️ يُنفَّذ على قاعدة الطعام (siro_food) لا primary.
|
||||
--
|
||||
-- sirodb siro_food < backend/food/migrations/2026_08_07_courier_settlement.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `food_courier_settlements` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`courier_id` VARCHAR(100) NOT NULL COMMENT 'معرّف السائق (نفس driver.id)',
|
||||
`period_start` DATETIME NOT NULL,
|
||||
`period_end` DATETIME NOT NULL,
|
||||
|
||||
`orders_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`payout_total` BIGINT NOT NULL DEFAULT 0 COMMENT 'مجموع أجور التوصيل المستحقة (أصغر وحدة)',
|
||||
`cash_debt_total` BIGINT NOT NULL DEFAULT 0 COMMENT 'مجموع ما حصّله نقداً ويخص المنصة',
|
||||
`carried_over` BIGINT NOT NULL DEFAULT 0 COMMENT 'دَين مُرحَّل من دورة سابقة (سالب الصافي)',
|
||||
`net_amount` BIGINT NOT NULL DEFAULT 0 COMMENT 'الصافي = payout − debt − carried_over',
|
||||
|
||||
`currency` VARCHAR(5) NOT NULL DEFAULT 'JOD',
|
||||
|
||||
`status` VARCHAR(20) NOT NULL DEFAULT 'draft'
|
||||
COMMENT 'paid = صُرفت | failed = تعذّر التحويل | carried = سالبة تُرحَّل للدورة القادمة | settled_forward = رُحّلت واستُهلكت في دورة لاحقة | draft',
|
||||
`transfer_code` SMALLINT NULL DEFAULT NULL COMMENT 'رمز HTTP من خادم المحفظة',
|
||||
`executed_by` VARCHAR(100) NULL DEFAULT NULL,
|
||||
`executed_at` DATETIME NULL DEFAULT NULL,
|
||||
`note` VARCHAR(500) NULL DEFAULT NULL,
|
||||
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY (`id`),
|
||||
-- يمنع توليد دورتين لنفس السائق ونفس الفترة — الازدواج هنا يعني صرفاً مضاعفاً
|
||||
UNIQUE KEY `uniq_courier_period` (`courier_id`, `period_start`, `period_end`),
|
||||
KEY `idx_status` (`status`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='دورات تسوية سائقي التوصيل — أجور مقاصّةً مع ديون النقد';
|
||||
|
||||
-- ربط قيود الدفع بدورة التسوية التي ضمّتها: بدونه لا نعرف ما سُوّي وما لم
|
||||
-- يُسوَّ، فتُحتسب نفس الأجرة مرتين في دورتين متتاليتين.
|
||||
ALTER TABLE `food_order_payments`
|
||||
ADD COLUMN `settlement_id` INT UNSIGNED NULL DEFAULT NULL
|
||||
COMMENT 'دورة التسوية التي ضمّت هذا القيد. NULL = لم يُسوَّ بعد',
|
||||
ADD KEY `idx_settlement` (`settlement_id`, `type`, `status`);
|
||||
|
||||
-- للتراجع:
|
||||
-- DROP TABLE `food_courier_settlements`;
|
||||
-- ALTER TABLE `food_order_payments` DROP COLUMN `settlement_id`, DROP KEY `idx_settlement`;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 2026_08_07_verify_food.sql — فحص حالة ترحيلات الطعام
|
||||
--
|
||||
-- قراءة خالصة لا تُعدّل شيئاً.
|
||||
-- sirodb siro_food < backend/food/migrations/2026_08_07_verify_food.sql
|
||||
|
||||
SELECT 'TABLE food_courier_settlements' AS item,
|
||||
COUNT(*) AS ok FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='food_courier_settlements'
|
||||
UNION ALL SELECT 'food_order_payments.settlement_id', COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='food_order_payments' AND COLUMN_NAME='settlement_id'
|
||||
UNION ALL SELECT 'food_order_payments.idx_settlement', COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='food_order_payments' AND INDEX_NAME='idx_settlement';
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
// food/order/call_courier.php — الزبون يتصل بسائق التوصيل عبر قناة مقنّعة
|
||||
// لا رقم هاتف يُعرض لأي طرف — session_id فقط، والقناة تُقفل بانتهاء الطلب.
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||
|
||||
if (!foodOrderAllowsCall($order)) {
|
||||
jsonError('Calling is only allowed while a courier is delivering your order', 403);
|
||||
}
|
||||
|
||||
if (foodCallQuotaExceeded($orderId, 'customer')) {
|
||||
jsonError('Too many call attempts for this order', 429);
|
||||
}
|
||||
|
||||
$courierId = (string)$order['courier_id'];
|
||||
$session = foodCreateCallSession($orderId, $courierId, $food_passenger_id);
|
||||
if (!$session) jsonError('Call service unavailable', 502);
|
||||
|
||||
// السائق يصله التنبيه عبر سوكيت الطعام (غرفة courier_food_{id}) — لا موضوع FCM
|
||||
// خاصاً بكل سائق في النظام، والسوكيت حيّ أصلاً أثناء وضع التوصيل.
|
||||
// الاسم المعروض عام عمداً: هوية الزبون لا تُكشف للسائق.
|
||||
foodPushToSocket('courier_call', [
|
||||
'order_id' => $orderId,
|
||||
'courier_id' => $courierId,
|
||||
'session_id' => $session['session_id'],
|
||||
'caller_name' => 'زبون الطلب #' . $orderId,
|
||||
'expires_in' => $session['expires_in'],
|
||||
]);
|
||||
|
||||
appLog("[FOOD][CALL] passenger {$food_passenger_id} → courier (order {$orderId})", 'INFO');
|
||||
|
||||
jsonSuccess([
|
||||
'session_id' => $session['session_id'],
|
||||
'expires_in' => $session['expires_in'],
|
||||
]);
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
// food/order/cancel.php — إلغاء الزبون قبل قبول المطعم فقط (بعده يتطلب اتصالاً بالمطعم)
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$reason = filterRequest('reason');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||
if ($order['status'] !== 'pending') {
|
||||
jsonError('Order can no longer be cancelled directly — it has already been accepted by the merchant', 409);
|
||||
}
|
||||
|
||||
food_transition_status($orderId, 'cancelled_by_customer', 'customer', $food_passenger_id, $reason);
|
||||
|
||||
if ($order['payment_method'] === 'wallet') {
|
||||
$refunded = foodWalletMove(
|
||||
$food_passenger_id, (int)$order['grand_total'], 'add',
|
||||
"food-refund-{$orderId}", "Food order #{$orderId} cancelled"
|
||||
);
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'release',?,?)"
|
||||
)->execute([$orderId, (int)$order['grand_total'], $refunded ? 'success' : 'failed']);
|
||||
|
||||
if (!$refunded) appLog("[FOOD][ORDER][cancel] refund FAILED for order $orderId — needs manual reconciliation", 'ERROR');
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'cancelled_by_customer']);
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
// food/order/courier_location.php — آخر موقع معروف لسائق طلبي (للراكب)
|
||||
//
|
||||
// مسار سحب (polling): تطبيق الراكب لا يحمل عميل سوكيت للطعام حالياً، وهذا
|
||||
// المصدر يكفي لخريطة تتبّع سلسة مع تحديث كل بضع ثوانٍ.
|
||||
// الموقع نفسه ينتهي من Redis خلال 90 ثانية، فبعد التسليم — أو بعد توقف
|
||||
// السائق عن البثّ — تُرجع الواجهة null بلا أي أثر متبقٍ.
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||
|
||||
// لا نكشف موقع السائق إلا خلال نافذة التوصيل — لا قبل الإسناد ولا بعد التسليم
|
||||
if (!in_array($order['status'], ['courier_assigned', 'picked_up'], true)) {
|
||||
jsonSuccess(['status' => $order['status'], 'courier_position' => null]);
|
||||
}
|
||||
|
||||
$position = null;
|
||||
if ($redis) {
|
||||
$raw = $redis->get("food:order:{$orderId}:courier_pos");
|
||||
if ($raw) $position = json_decode($raw, true);
|
||||
}
|
||||
|
||||
// إحداثيات وجهتَي الخريطة — المطعم وعنوان التسليم (كلاهما معروف للزبون أصلاً)
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.delivery_lat, o.delivery_lng, o.delivery_address,
|
||||
m.name_ar AS merchant_name_ar, m.latitude AS merchant_lat, m.longitude AS merchant_lng
|
||||
FROM food_orders o JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.id = ? LIMIT 1"
|
||||
);
|
||||
$st->execute([$orderId]);
|
||||
$meta = $st->fetch() ?: [];
|
||||
|
||||
jsonSuccess([
|
||||
'status' => $order['status'],
|
||||
'courier_position' => $position,
|
||||
'merchant' => $meta ? [
|
||||
'name_ar' => $meta['merchant_name_ar'],
|
||||
'lat' => (float)$meta['merchant_lat'],
|
||||
'lng' => (float)$meta['merchant_lng'],
|
||||
] : null,
|
||||
'destination' => $meta ? [
|
||||
'address' => $meta['delivery_address'],
|
||||
'lat' => (float)$meta['delivery_lat'],
|
||||
'lng' => (float)$meta['delivery_lng'],
|
||||
] : null,
|
||||
]);
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
// food/order/create.php — إنشاء الطلب من عرض سعر موقّع + خصم فوري من المحفظة
|
||||
// body: quote_token, client_order_uuid, delivery_address, delivery_lat, delivery_lng,
|
||||
// payment_method (wallet|cash), customer_note
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
requireFoodFields(['quote_token', 'client_order_uuid', 'delivery_address', 'delivery_lat', 'delivery_lng']);
|
||||
|
||||
$quoteToken = filterRequest('quote_token');
|
||||
$clientUuid = filterRequest('client_order_uuid');
|
||||
$address = filterRequest('delivery_address');
|
||||
$lat = filterRequest('delivery_lat', 'float');
|
||||
$lng = filterRequest('delivery_lng', 'float');
|
||||
$paymentMethod = filterRequest('payment_method') ?: 'wallet';
|
||||
$note = filterRequest('customer_note');
|
||||
|
||||
if (!in_array($paymentMethod, ['wallet', 'cash'], true)) jsonError('Invalid payment_method');
|
||||
if (!preg_match('/^[0-9a-fA-F-]{36}$/', $clientUuid)) jsonError('client_order_uuid must be a valid UUID');
|
||||
|
||||
$quote = foodVerifyQuote($quoteToken);
|
||||
if ((string)$quote['passenger_id'] !== $food_passenger_id) jsonError('Quote does not belong to this session', 403);
|
||||
|
||||
// idempotency — الضغط المزدوج أو إعادة محاولة الشبكة يرجع نفس الطلب لا طلباً جديداً
|
||||
$dupSt = $food_con->prepare("SELECT id FROM food_orders WHERE client_order_uuid=? LIMIT 1");
|
||||
$dupSt->execute([$clientUuid]);
|
||||
if ($existing = $dupSt->fetch()) {
|
||||
jsonSuccess(['order_id' => (int)$existing['id'], 'idempotent_replay' => true], 'Order already exists');
|
||||
}
|
||||
|
||||
$maxActive = (int)(getenv('FOOD_MAX_ACTIVE_ORDERS_PER_USER') ?: 3);
|
||||
$activeSt = $food_con->prepare(
|
||||
"SELECT COUNT(*) c FROM food_orders WHERE passenger_id=? AND status NOT IN
|
||||
('delivered','rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system')"
|
||||
);
|
||||
$activeSt->execute([$food_passenger_id]);
|
||||
if ((int)$activeSt->fetch()['c'] >= $maxActive) {
|
||||
jsonError("You already have $maxActive active orders — finish or cancel one first", 409);
|
||||
}
|
||||
|
||||
$merchantSt = $food_con->prepare("SELECT id, status, commission_percent FROM food_merchants WHERE id=? LIMIT 1");
|
||||
$merchantSt->execute([$quote['merchant_id']]);
|
||||
$merchant = $merchantSt->fetch();
|
||||
if (!$merchant || $merchant['status'] !== 'active') jsonError('Merchant is no longer available', 409);
|
||||
|
||||
$itemsTotal = (int)$quote['items_total'];
|
||||
$deliveryFee = (int)$quote['delivery_fee'];
|
||||
$serviceFee = (int)$quote['service_fee'];
|
||||
$grandTotal = (int)$quote['grand_total'];
|
||||
$commission = foodComputeCommission($itemsTotal, (float)$merchant['commission_percent']);
|
||||
|
||||
if ($paymentMethod === 'wallet') {
|
||||
$balance = foodWalletGetBalance($food_passenger_id);
|
||||
if ($balance === null) jsonError('Unable to verify wallet balance. Please try again.', 503);
|
||||
if ($balance < foodSmallestUnitToDecimal($grandTotal)) {
|
||||
jsonError('Insufficient wallet balance', 402, ['current_balance' => $balance]);
|
||||
}
|
||||
}
|
||||
|
||||
$food_con->beginTransaction();
|
||||
try {
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_orders
|
||||
(client_order_uuid, passenger_id, merchant_id, status, items_total, delivery_fee, service_fee,
|
||||
discount, grand_total, commission_amount, payment_method, delivery_address, delivery_lat,
|
||||
delivery_lng, customer_note)
|
||||
VALUES (?,?,?,'pending',?,?,?,0,?,?,?,?,?,?,?)"
|
||||
)->execute([
|
||||
$clientUuid, $food_passenger_id, $quote['merchant_id'], $itemsTotal, $deliveryFee, $serviceFee,
|
||||
$grandTotal, $commission, $paymentMethod, $address, $lat, $lng, $note,
|
||||
]);
|
||||
$orderId = (int)$food_con->lastInsertId();
|
||||
|
||||
$insertLine = $food_con->prepare(
|
||||
"INSERT INTO food_order_items (order_id, item_id, name_ar_snapshot, unit_price, quantity, option_price_json, line_total)
|
||||
VALUES (?,?,?,?,?,?,?)"
|
||||
);
|
||||
foreach ($quote['lines'] as $line) {
|
||||
$insertLine->execute([
|
||||
$orderId, $line['item_id'], $line['name_ar_snapshot'], $line['unit_price'],
|
||||
$line['quantity'], json_encode($line['options']), $line['line_total'],
|
||||
]);
|
||||
}
|
||||
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_status_log (order_id, from_status, to_status, actor_type, actor_id)
|
||||
VALUES (?,NULL,'pending','customer',?)"
|
||||
)->execute([$orderId, $food_passenger_id]);
|
||||
|
||||
$food_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$food_con->rollBack();
|
||||
appLog('[FOOD][ORDER][create] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to create order', 500);
|
||||
}
|
||||
|
||||
if ($paymentMethod === 'wallet') {
|
||||
$debited = foodWalletMove($food_passenger_id, $grandTotal, 'subtract', "food-order-{$orderId}", "Food order #{$orderId}");
|
||||
if (!$debited) {
|
||||
// فشل الخصم بعد إنشاء السجل — نُلغي الطلب فوراً بدل ترك طلب بلا دفع
|
||||
food_transition_status($orderId, 'cancelled_system', 'system', 'wallet', 'Wallet debit failed');
|
||||
jsonError('Wallet payment failed — order cancelled', 402);
|
||||
}
|
||||
$food_con->prepare(
|
||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'hold',?,'success')"
|
||||
)->execute([$orderId, $grandTotal]);
|
||||
}
|
||||
|
||||
foodPushToSocket('order_status_update', [
|
||||
'order_id' => $orderId, 'status' => 'pending', 'passenger_id' => $food_passenger_id,
|
||||
'merchant_id' => (int)$quote['merchant_id'], 'courier_id' => null,
|
||||
]);
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'status' => 'pending', 'grand_total' => $grandTotal], 'Order placed');
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
// food/order/history.php — سجل طلبات الزبون
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$page = max(1, (int)(filterRequest('page', 'int') ?? 1));
|
||||
$limit = 20;
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
$st = $food_con->prepare(
|
||||
"SELECT o.id, o.status, o.grand_total, o.created_at, o.delivered_at, o.rating,
|
||||
m.name_ar AS merchant_name_ar, m.logo_url AS merchant_logo_url
|
||||
FROM food_orders o
|
||||
JOIN food_merchants m ON m.id = o.merchant_id
|
||||
WHERE o.passenger_id = ?
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT $limit OFFSET $offset"
|
||||
);
|
||||
$st->execute([$food_passenger_id]);
|
||||
|
||||
jsonSuccess(['orders' => $st->fetchAll(), 'page' => $page]);
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
// food/order/rate.php — تقييم الطلب بعد التسليم (يحدّث متوسط تقييم المطعم)
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
$rating = filterRequest('rating', 'int');
|
||||
$comment = filterRequest('comment');
|
||||
|
||||
if (!$orderId || !$rating || $rating < 1 || $rating > 5) jsonError('order_id and rating (1-5) are required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||
if ($order['status'] !== 'delivered') jsonError('Only delivered orders can be rated', 409);
|
||||
if ($order['rating'] !== null) jsonError('Order already rated', 409);
|
||||
|
||||
$food_con->beginTransaction();
|
||||
try {
|
||||
$food_con->prepare("UPDATE food_orders SET rating=?, rating_comment=? WHERE id=?")
|
||||
->execute([$rating, $comment, $orderId]);
|
||||
|
||||
$food_con->prepare(
|
||||
"UPDATE food_merchants SET
|
||||
rating_avg = ((rating_avg * rating_count) + ?) / (rating_count + 1),
|
||||
rating_count = rating_count + 1
|
||||
WHERE id=?"
|
||||
)->execute([$rating, $order['merchant_id']]);
|
||||
|
||||
$food_con->commit();
|
||||
} catch (Throwable $e) {
|
||||
$food_con->rollBack();
|
||||
appLog('[FOOD][ORDER][rate] ' . $e->getMessage(), 'ERROR');
|
||||
jsonError('Failed to save rating', 500);
|
||||
}
|
||||
|
||||
jsonSuccess(['order_id' => $orderId, 'rating' => $rating]);
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
// food/order/status.php — حالة الطلب الحالية (مصدر الحقيقة عند إعادة الاتصال بالسوكيت)
|
||||
require_once __DIR__ . '/../connect_app.php';
|
||||
|
||||
$orderId = filterRequest('order_id', 'int');
|
||||
if (!$orderId) jsonError('order_id is required');
|
||||
|
||||
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||
|
||||
$itemsSt = $food_con->prepare("SELECT name_ar_snapshot, unit_price, quantity, line_total FROM food_order_items WHERE order_id=?");
|
||||
$itemsSt->execute([$orderId]);
|
||||
$order['items'] = $itemsSt->fetchAll();
|
||||
|
||||
// عناوين/أرقام الزبون تُحجب عن السائق قبل courier_assigned وبعد delivered — هنا هي بوابة الزبون نفسه فلا حجب
|
||||
jsonSuccess(['order' => $order]);
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
// food/ping.php — فحص الأساس: الحاوية php_food + قاعدة siro_food تعملان
|
||||
require_once __DIR__ . '/connect_app.php';
|
||||
|
||||
$food_con->query('SELECT 1');
|
||||
jsonSuccess(['service' => 'food', 'db' => 'ok'], 'pong');
|
||||
@@ -0,0 +1,289 @@
|
||||
-- =============================================================
|
||||
-- schema_food.sql — قاعدة بيانات وحدة طلبات الطعام (siro_food)
|
||||
-- عزل كامل عن main/ride/transit — ممنوع أي JOIN خارجي
|
||||
-- الربط بالنظام الرئيسي عبر passenger_id / courier_id (main driver id) فقط
|
||||
-- =============================================================
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
|
||||
SET time_zone = "+00:00";
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 1. food_merchants — المطاعم/المتاجر
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_merchants` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name_ar` VARCHAR(150) NOT NULL,
|
||||
`name_en` VARCHAR(150) DEFAULT NULL,
|
||||
`logo_url` VARCHAR(500) DEFAULT NULL,
|
||||
`cover_url` VARCHAR(500) DEFAULT NULL,
|
||||
`description_ar` VARCHAR(500) DEFAULT NULL,
|
||||
`city` VARCHAR(80) NOT NULL,
|
||||
`address` VARCHAR(300) DEFAULT NULL,
|
||||
`latitude` DECIMAL(10,7) NOT NULL,
|
||||
`longitude` DECIMAL(10,7) NOT NULL,
|
||||
`category` VARCHAR(60) DEFAULT NULL COMMENT 'مطبخ عربي، بيتزا، حلويات...',
|
||||
`commission_percent` DECIMAL(5,2) NOT NULL DEFAULT 15.00,
|
||||
`min_order_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'أصغر وحدة نقدية',
|
||||
`avg_prep_minutes` SMALLINT UNSIGNED NOT NULL DEFAULT 20,
|
||||
`rating_avg` DECIMAL(3,2) NOT NULL DEFAULT 0.00,
|
||||
`rating_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`status` ENUM('pending_approval','active','paused','suspended','rejected') NOT NULL DEFAULT 'pending_approval',
|
||||
`is_open_override` TINYINT(1) DEFAULT NULL COMMENT 'NULL=يتبع working_hours، 0/1=إغلاق/فتح يدوي فوري',
|
||||
`working_hours` JSON DEFAULT NULL COMMENT '{"sun":[["09:00","23:00"]], ...}',
|
||||
`approved_by` INT UNSIGNED DEFAULT NULL COMMENT 'admin id من النظام الرئيسي',
|
||||
`approved_at` TIMESTAMP DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_city_status` (`city`, `status`),
|
||||
KEY `idx_location` (`latitude`, `longitude`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 2. food_merchant_users — حسابات دخول أصحاب المطاعم (دور merchant، هوية منفصلة عن الراكب)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_merchant_users` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(120) NOT NULL,
|
||||
`phone` VARCHAR(25) NOT NULL COMMENT 'مشفّر بنفس EncryptionHelper',
|
||||
`role` ENUM('owner','staff') NOT NULL DEFAULT 'owner',
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_phone` (`phone`),
|
||||
KEY `idx_merchant` (`merchant_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 3. food_menu_categories — أقسام قائمة المطعم
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_menu_categories` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`name_ar` VARCHAR(100) NOT NULL,
|
||||
`name_en` VARCHAR(100) DEFAULT NULL,
|
||||
`sort_order` SMALLINT NOT NULL DEFAULT 0,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_merchant_sort` (`merchant_id`, `sort_order`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 4. food_menu_items — أصناف القائمة
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_menu_items` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`category_id` INT UNSIGNED NOT NULL,
|
||||
`name_ar` VARCHAR(150) NOT NULL,
|
||||
`name_en` VARCHAR(150) DEFAULT NULL,
|
||||
`description_ar` VARCHAR(500) DEFAULT NULL,
|
||||
`image_url` VARCHAR(500) DEFAULT NULL,
|
||||
`price` BIGINT UNSIGNED NOT NULL COMMENT 'أصغر وحدة نقدية',
|
||||
`is_available` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`prep_minutes` SMALLINT UNSIGNED DEFAULT NULL COMMENT 'NULL = يستخدم avg_prep_minutes للمطعم',
|
||||
`sort_order` SMALLINT NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_merchant_available` (`merchant_id`, `is_available`),
|
||||
KEY `idx_category` (`category_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 5. food_item_options — مجموعات خيارات الصنف (حجم، إضافات...)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_item_options` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`item_id` INT UNSIGNED NOT NULL,
|
||||
`group_name_ar` VARCHAR(100) NOT NULL COMMENT 'مثال: الحجم، الإضافات',
|
||||
`is_required` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`max_select` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=اختيار واحد، أكثر=متعدد',
|
||||
`choices` JSON NOT NULL COMMENT '[{"id":"lg","label_ar":"كبير","price":500}]',
|
||||
`sort_order` SMALLINT NOT NULL DEFAULT 0,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_item` (`item_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 6. food_orders — الطلب
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_orders` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`client_order_uuid` CHAR(36) NOT NULL COMMENT 'idempotency — يمنع الضغط المزدوج من إنشاء طلبين',
|
||||
`passenger_id` VARCHAR(100) NOT NULL COMMENT 'مرجع منطقي فقط — لا FK خارجي',
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`courier_id` VARCHAR(100) DEFAULT NULL COMMENT 'main driver id بعد الإسناد',
|
||||
`status` ENUM(
|
||||
'pending','merchant_accepted','preparing','ready',
|
||||
'courier_assigned','picked_up','delivered',
|
||||
'rejected','cancelled_by_customer','cancelled_by_merchant','cancelled_system'
|
||||
) NOT NULL DEFAULT 'pending',
|
||||
`items_total` BIGINT UNSIGNED NOT NULL COMMENT 'مجموع أصناف الطلب — أصغر وحدة نقدية',
|
||||
`delivery_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`service_fee` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`discount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`grand_total` BIGINT UNSIGNED NOT NULL COMMENT 'items_total+delivery_fee+service_fee-discount',
|
||||
`commission_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'نصيب المنصة من items_total',
|
||||
`payment_method` ENUM('wallet','cash') NOT NULL DEFAULT 'wallet',
|
||||
`payment_hold_ref` VARCHAR(100) DEFAULT NULL COMMENT 'مرجع حجز المحفظة في payment_server/v2',
|
||||
`delivery_address` VARCHAR(300) NOT NULL,
|
||||
`delivery_lat` DECIMAL(10,7) NOT NULL,
|
||||
`delivery_lng` DECIMAL(10,7) NOT NULL,
|
||||
`customer_note` VARCHAR(300) DEFAULT NULL,
|
||||
`promo_code` VARCHAR(40) DEFAULT NULL,
|
||||
`rating` TINYINT UNSIGNED DEFAULT NULL,
|
||||
`rating_comment` VARCHAR(300) DEFAULT NULL,
|
||||
`merchant_accepted_at` TIMESTAMP DEFAULT NULL,
|
||||
`ready_at` TIMESTAMP DEFAULT NULL,
|
||||
`courier_assigned_at` TIMESTAMP DEFAULT NULL,
|
||||
`picked_up_at` TIMESTAMP DEFAULT NULL,
|
||||
`delivered_at` TIMESTAMP DEFAULT NULL,
|
||||
`cancelled_at` TIMESTAMP DEFAULT NULL,
|
||||
`cancel_reason` VARCHAR(300) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_client_uuid` (`client_order_uuid`),
|
||||
KEY `idx_passenger_created` (`passenger_id`, `created_at`),
|
||||
KEY `idx_merchant_status` (`merchant_id`, `status`),
|
||||
KEY `idx_courier_status` (`courier_id`, `status`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 7. food_order_items — أصناف الطلب بسعر مجمّد وقت الطلب
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_order_items` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`order_id` INT UNSIGNED NOT NULL,
|
||||
`item_id` INT UNSIGNED NOT NULL COMMENT 'مرجع فقط — لا JOIN للسعر',
|
||||
`name_ar_snapshot` VARCHAR(150) NOT NULL,
|
||||
`unit_price` BIGINT UNSIGNED NOT NULL COMMENT 'سعر الوحدة وقت الطلب — مجمّد',
|
||||
`quantity` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`option_price_json` JSON DEFAULT NULL COMMENT 'الخيارات المختارة وأسعارها وقت الطلب',
|
||||
`line_total` BIGINT UNSIGNED NOT NULL COMMENT '(unit_price+مجموع الخيارات)×quantity',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order` (`order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 8. food_order_status_log — كل انتقال حالة — مصدر الحقيقة للنزاعات
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_order_status_log` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`order_id` INT UNSIGNED NOT NULL,
|
||||
`from_status` VARCHAR(30) DEFAULT NULL,
|
||||
`to_status` VARCHAR(30) NOT NULL,
|
||||
`actor_type` ENUM('customer','merchant','courier','admin','system') NOT NULL,
|
||||
`actor_id` VARCHAR(100) DEFAULT NULL,
|
||||
`note` VARCHAR(300) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order` (`order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 9. food_order_payments — مرجع معاملة المحفظة/الدفع + التسوية
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_order_payments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`order_id` INT UNSIGNED NOT NULL,
|
||||
`type` ENUM('hold','capture','release','cash_settlement','courier_payout') NOT NULL,
|
||||
`amount` BIGINT UNSIGNED NOT NULL,
|
||||
`wallet_ref` VARCHAR(100) DEFAULT NULL COMMENT 'مرجع من payment_server/v2',
|
||||
`status` ENUM('pending','success','failed') NOT NULL DEFAULT 'pending',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order` (`order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 10. food_courier_assignments — محاولات إسناد الطلب لسائق
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_courier_assignments` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`order_id` INT UNSIGNED NOT NULL,
|
||||
`courier_id` VARCHAR(100) NOT NULL COMMENT 'main driver id',
|
||||
`status` ENUM('offered','accepted','rejected','timed_out') NOT NULL DEFAULT 'offered',
|
||||
`offered_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`responded_at` TIMESTAMP DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_order` (`order_id`),
|
||||
KEY `idx_courier` (`courier_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 11. food_merchant_payouts — مستحقات المطاعم ودورات التسوية
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_merchant_payouts` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`period_start` DATE NOT NULL,
|
||||
`period_end` DATE NOT NULL,
|
||||
`orders_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`gross_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`commission_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`net_payout` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`status` ENUM('pending','paid') NOT NULL DEFAULT 'pending',
|
||||
`paid_at` TIMESTAMP DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_merchant_period` (`merchant_id`, `period_start`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 12. food_promo_codes — أكواد خصم خاصة بالطعام (منفصلة عن أكواد الرحلات)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_promo_codes` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`code` VARCHAR(40) NOT NULL,
|
||||
`discount_type` ENUM('percent','fixed') NOT NULL DEFAULT 'fixed',
|
||||
`discount_value` INT UNSIGNED NOT NULL,
|
||||
`max_discount` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`min_order_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`usage_limit` INT UNSIGNED DEFAULT NULL,
|
||||
`usage_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`valid_from` TIMESTAMP DEFAULT NULL,
|
||||
`valid_until` TIMESTAMP DEFAULT NULL,
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_code` (`code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- 13. food_merchant_sessions — جلسات دخول لوحة المطعم (هاتف+كلمة مرور،
|
||||
-- session token مستقل عن JWT — نفس نمط transit_sessions، لأن لوحة
|
||||
-- المطعم ويب متجاوب لا يملك device fingerprint كتطبيق الجوال)
|
||||
-- -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `food_merchant_sessions` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`merchant_user_id` INT UNSIGNED NOT NULL,
|
||||
`merchant_id` INT UNSIGNED NOT NULL,
|
||||
`token_hash` VARCHAR(64) NOT NULL COMMENT 'sha256 للـ session token',
|
||||
`ip` VARCHAR(45) DEFAULT NULL,
|
||||
`user_agent` VARCHAR(300) DEFAULT NULL,
|
||||
`expires_at` TIMESTAMP NOT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_token` (`token_hash`),
|
||||
KEY `idx_merchant_user` (`merchant_user_id`),
|
||||
KEY `idx_expires` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- -----------------------------------------------------------------
|
||||
-- ملاحظات Redis (مفاتيح تُكتب من PHP — ليست في SQL):
|
||||
-- food:order:{id}:lock → SET NX EX 20 عند قبول سائق (يمنع سباق القبول)
|
||||
-- geo:couriers:available → مجموعة جغرافية للسائقين can_deliver=1 المتفرغين
|
||||
-- food:merchant:{id}:cache → كاش بيانات مطعم للقراءة السريعة (اختياري لاحقاً)
|
||||
-- Redis channels للسوكيت: food:order:{id} / food:merchant:{id} / food:courier:{id}
|
||||
-- -----------------------------------------------------------------
|
||||
Reference in New Issue
Block a user