Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-09 16:56:13 +03:00
parent 95e2e4f35d
commit b64debaa88
1058 changed files with 164327 additions and 113928 deletions
+231
View File
@@ -0,0 +1,231 @@
<?php
// ============================================================
// food/admin/courier_settlement.php — تسوية أرباح سائق التوصيل
//
// ‏قرار المالك: الأجرة تتراكم وتُصرف **دورياً مع مقاصّة دَين النقد**.
//
// ‏لماذا المقاصّة: في الطلب النقدي يحصّل السائق grand_total كاملاً من
// ‏الزبون، فيصير مديناً للمنصة بحصة المطعم والعمولة. صرف أجرته منفصلاً
// ‏عن هذا الدَين يعني أن تدفع له بينما هو يحمل مالك.
//
// الصافي = أجور التوصيل − ديون النقد − المُرحَّل من دورة سابقة
//
// ‏عمليتان في نقطة واحدة:
// action=preview (الافتراضي) — يحسب ويعرض بلا أي أثر
// action=execute — يثبّت الدورة ويصرف إن كان الصافي موجباً
//
// ‏الفصل مقصود: صرف المال يجب أن يسبقه اطّلاع، لا أن يكون أثراً جانبياً
// ‏لفتح شاشة.
// ============================================================
require_once __DIR__ . '/../connect_admin.php';
require_once __DIR__ . '/../../ride/pricing/pricing_helper.php';
$courierId = filterRequest('courier_id');
$periodStart = filterRequest('period_start');
$periodEnd = filterRequest('period_end');
$action = filterRequest('action') === 'execute' ? 'execute' : 'preview';
requireFoodFields(['courier_id', 'period_start', 'period_end']);
// ‏عملة الطعام تتبع دولة التشغيل. الأردن أولاً — والثابت يبقى مقروءاً من
// ‏البيئة حتى لا يتحول إلى رقم مدفون عند التوسع.
$currency = strtoupper((string) (getenv('FOOD_CURRENCY') ?: 'JOD'));
try {
// ══════════════════════════════════════════════════════════
// ١) القيود غير المسوّاة في الفترة
//
// ‏settlement_id IS NULL هو الضمانة ضد الاحتساب المزدوج: قيد ضُمّ
// ‏لدورة سابقة لا يعود في هذه.
// ══════════════════════════════════════════════════════════
$st = $food_con->prepare("
SELECT p.id, p.type, p.amount
FROM food_order_payments p
JOIN food_orders o ON o.id = p.order_id
WHERE o.courier_id = ?
AND o.delivered_at BETWEEN ? AND ?
AND p.settlement_id IS NULL
AND p.type IN ('courier_payout', 'cash_settlement')
");
$st->execute([$courierId, $periodStart, $periodEnd]);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
$payoutTotal = 0;
$cashDebt = 0;
$payoutIds = [];
foreach ($rows as $r) {
$payoutIds[] = (int) $r['id'];
if ($r['type'] === 'courier_payout') {
$payoutTotal += (int) $r['amount'];
} else {
$cashDebt += (int) $r['amount'];
}
}
// ══════════════════════════════════════════════════════════
// ٢) المُرحَّل من دورة سابقة سالبة
// ══════════════════════════════════════════════════════════
$stCarry = $food_con->prepare("
SELECT COALESCE(SUM(-net_amount), 0)
FROM food_courier_settlements
WHERE courier_id = ? AND status = 'carried'
");
$stCarry->execute([$courierId]);
$carriedOver = (int) $stCarry->fetchColumn();
$net = $payoutTotal - $cashDebt - $carriedOver;
$stCount = $food_con->prepare("
SELECT COUNT(*) FROM food_orders
WHERE courier_id = ? AND status = 'delivered'
AND delivered_at BETWEEN ? AND ?
");
$stCount->execute([$courierId, $periodStart, $periodEnd]);
$ordersCount = (int) $stCount->fetchColumn();
$summary = [
'courier_id' => $courierId,
'period_start' => $periodStart,
'period_end' => $periodEnd,
'orders_count' => $ordersCount,
'payout_total' => $payoutTotal,
'cash_debt_total' => $cashDebt,
'carried_over' => $carriedOver,
'net_amount' => $net,
'net_decimal' => foodSmallestUnitToDecimal(abs($net)) * ($net < 0 ? -1 : 1),
'currency' => $currency,
'entries_count' => count($payoutIds),
];
// ── المعاينة تتوقف هنا ──────────────────────────────────
if ($action === 'preview') {
$summary['action'] = 'preview';
$summary['would_pay'] = $net > 0;
jsonSuccess($summary, 'Settlement preview');
}
// ══════════════════════════════════════════════════════════
// ٣) التنفيذ
// ══════════════════════════════════════════════════════════
if (empty($payoutIds) && $carriedOver === 0) {
jsonError('No unsettled entries for this courier and period');
}
$status = $net > 0 ? 'draft' : 'carried';
$food_con->beginTransaction();
$ins = $food_con->prepare("
INSERT INTO food_courier_settlements
(courier_id, period_start, period_end, orders_count,
payout_total, cash_debt_total, carried_over, net_amount,
currency, status, executed_by, executed_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW())
");
$ins->execute([
$courierId, $periodStart, $periodEnd, $ordersCount,
$payoutTotal, $cashDebt, $carriedOver, $net,
$currency, $status, $food_admin_id ?: 'admin',
]);
$settlementId = (int) $food_con->lastInsertId();
// ‏وسم القيود قبل الصرف: لو انقطع التنفيذ بعد التحويل وقبل الوسم،
// ‏لأُعيد صرفها في الدورة التالية.
if (!empty($payoutIds)) {
$ph = implode(',', array_fill(0, count($payoutIds), '?'));
$food_con->prepare("
UPDATE food_order_payments
SET settlement_id = ?, status = 'success'
WHERE id IN ($ph)
")->execute(array_merge([$settlementId], $payoutIds));
}
// ‏الدورات السالبة المُرحَّلة استُهلكت في هذا الحساب — نغلقها حتى لا
// ‏تُطرح مرة أخرى في الدورة القادمة.
if ($carriedOver > 0) {
$food_con->prepare("
UPDATE food_courier_settlements
SET status = 'settled_forward', note = CONCAT(COALESCE(note,''), ' → #', ?)
WHERE courier_id = ? AND status = 'carried' AND id <> ?
")->execute([$settlementId, $courierId, $settlementId]);
}
$food_con->commit();
// ══════════════════════════════════════════════════════════
// ٤) التحويل — بعد الـcommit لا داخله
// ══════════════════════════════════════════════════════════
$transferCode = null;
if ($net > 0) {
$transferCode = foodPayCourier(
$courierId,
foodSmallestUnitToDecimal($net),
$settlementId
);
$food_con->prepare("
UPDATE food_courier_settlements
SET status = ?, transfer_code = ?
WHERE id = ?
")->execute([$transferCode === 200 ? 'paid' : 'failed', $transferCode, $settlementId]);
if ($transferCode !== 200) {
error_log("[FOOD][SETTLEMENT] MONEY: تسوية #$settlementId بقيمة $net"
. " للسائق $courierId لم تصل (رمز=$transferCode)");
}
}
$summary['settlement_id'] = $settlementId;
$summary['status'] = $net > 0
? ($transferCode === 200 ? 'paid' : 'failed')
: 'carried';
$summary['transfer_code'] = $transferCode;
jsonSuccess($summary, 'Settlement executed');
} catch (PDOException $e) {
if (isset($food_con) && $food_con->inTransaction()) $food_con->rollBack();
error_log("[FOOD][SETTLEMENT] " . $e->getMessage());
jsonError('DB Error', 500);
}
/**
* ‏يودع صافي التسوية في محفظة السائق.
*
* ‏يستخدم driverWallet/add_s2s_reward.php — وهو العقد الذي أكّدناه عملياً
* ‏في تعويض عدم الحضور ورسوم الإلغاء وتعويضات الشكاوى. تعليق delivered.php
* ‏يقول إن العقد المؤكد لتحويلات سائق↔سائق فقط، وهذا لم يعد صحيحاً.
*
* ‏paymentID مشتق من رقم التسوية فيمنع ازدواج الصرف عند إعادة المحاولة.
*/
function foodPayCourier(string $courierId, float $amount, int $settlementId): int
{
$walletServer = foodWalletServerUrl();
$url = "$walletServer/v2/main/ride/driverWallet/add_s2s_reward.php";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'driverID' => $courierId,
'paymentID' => "food_settlement_$settlementId",
'amount' => $amount,
'paymentMethod' => 'food_courier_settlement',
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
'X-S2S-Api-Key: ' . getenv('S2S_SHARED_KEY'),
],
]);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return $code;
}
+23
View File
@@ -0,0 +1,23 @@
<?php
// food/admin/merchant_approve.php — اعتماد أو رفض مطعم بحالة pending_approval
require_once __DIR__ . '/../connect_admin.php';
$merchantId = filterRequest('merchant_id', 'int');
$action = filterRequest('action'); // 'approve' | 'reject'
if (!$merchantId || !in_array($action, ['approve', 'reject'], true)) {
jsonError('merchant_id and action (approve|reject) are required');
}
$st = $food_con->prepare("SELECT id, status FROM food_merchants WHERE id=? LIMIT 1");
$st->execute([$merchantId]);
$merchant = $st->fetch();
if (!$merchant) jsonError('Merchant not found', 404);
if ($merchant['status'] !== 'pending_approval') jsonError('Merchant is not pending approval', 409);
$newStatus = $action === 'approve' ? 'active' : 'rejected';
$food_con->prepare(
"UPDATE food_merchants SET status=?, approved_by=?, approved_at=NOW() WHERE id=?"
)->execute([$newStatus, $food_admin_id, $merchantId]);
jsonSuccess(['merchant_id' => $merchantId, 'status' => $newStatus]);
+48
View File
@@ -0,0 +1,48 @@
<?php
// food/admin/merchant_create.php — إنشاء مطعم جديد بحالة pending_approval + حساب مالكه
require_once __DIR__ . '/../connect_admin.php';
requireFoodFields(['name_ar', 'city', 'address', 'latitude', 'longitude', 'owner_name', 'owner_phone', 'owner_password']);
$nameAr = filterRequest('name_ar');
$nameEn = filterRequest('name_en');
$city = filterRequest('city');
$address = filterRequest('address');
$lat = filterRequest('latitude', 'float');
$lng = filterRequest('longitude', 'float');
$category = filterRequest('category');
$commission = filterRequest('commission_percent', 'float') ?? (float)(getenv('FOOD_COMMISSION_PERCENT') ?: 15);
$ownerName = filterRequest('owner_name');
$ownerPhone = normalizePhone(filterRequest('owner_phone'));
$ownerPassword = filterRequest('owner_password');
if (strlen($ownerPassword) < 8) jsonError('owner_password must be at least 8 characters');
$dupSt = $food_con->prepare("SELECT id FROM food_merchant_users WHERE phone=? LIMIT 1");
$dupSt->execute([$ownerPhone]);
if ($dupSt->fetch()) jsonError('A merchant account already uses this phone', 409);
$food_con->beginTransaction();
try {
$food_con->prepare(
"INSERT INTO food_merchants
(name_ar, name_en, city, address, latitude, longitude, category, commission_percent, status)
VALUES (?,?,?,?,?,?,?,?,'pending_approval')"
)->execute([$nameAr, $nameEn, $city, $address, $lat, $lng, $category, $commission]);
$merchantId = (int)$food_con->lastInsertId();
$food_con->prepare(
"INSERT INTO food_merchant_users (merchant_id, name, phone, role, password_hash)
VALUES (?,?,?,'owner',?)"
)->execute([$merchantId, $ownerName, $ownerPhone, password_hash($ownerPassword, PASSWORD_DEFAULT)]);
$food_con->commit();
} catch (Throwable $e) {
$food_con->rollBack();
appLog('[FOOD][ADMIN][merchant_create] ' . $e->getMessage(), 'ERROR');
jsonError('Failed to create merchant', 500);
}
jsonSuccess(['merchant_id' => $merchantId, 'status' => 'pending_approval'], 'Merchant created — awaiting approval');
+22
View File
@@ -0,0 +1,22 @@
<?php
// food/admin/merchants.php — قائمة المطاعم لإدارة سيرو (فلترة حسب الحالة)
require_once __DIR__ . '/../connect_admin.php';
$status = filterRequest('status') ?: 'all';
$allowed = ['all', 'pending_approval', 'active', 'paused', 'suspended', 'rejected'];
if (!in_array($status, $allowed, true)) $status = 'all';
$sql = "SELECT id, name_ar, name_en, city, category, status, commission_percent,
rating_avg, rating_count, created_at, approved_at
FROM food_merchants";
$params = [];
if ($status !== 'all') {
$sql .= " WHERE status=?";
$params[] = $status;
}
$sql .= " ORDER BY created_at DESC";
$st = $food_con->prepare($sql);
$st->execute($params);
jsonSuccess(['merchants' => $st->fetchAll()]);
+39
View File
@@ -0,0 +1,39 @@
<?php
// food/admin/payouts.php — تقرير تسويات المطاعم للفترة المطلوبة (توليد لا صرف آلي)
//
// ⚠️ هذا يُنتج تقريراً محاسبياً (food_merchant_payouts بحالة pending) — لا يحوّل
// أموالاً فعلياً. الصرف الفعلي للمطاعم والسائقين خارج نطاق هذه الوحدة حتى يُؤكَّد
// عقد S2S مخصص لذلك (انظر التنبيهات في food/functions.php حول foodWalletMove).
require_once __DIR__ . '/../connect_admin.php';
$merchantId = filterRequest('merchant_id', 'int');
$periodStart = filterRequest('period_start');
$periodEnd = filterRequest('period_end');
requireFoodFields(['merchant_id', 'period_start', 'period_end']);
$st = $food_con->prepare(
"SELECT COUNT(*) orders_count, COALESCE(SUM(items_total),0) gross_amount, COALESCE(SUM(commission_amount),0) commission_amount
FROM food_orders
WHERE merchant_id=? AND status='delivered' AND delivered_at BETWEEN ? AND ?"
);
$st->execute([$merchantId, $periodStart, $periodEnd]);
$summary = $st->fetch();
$netPayout = (int)$summary['gross_amount'] - (int)$summary['commission_amount'];
$food_con->prepare(
"INSERT INTO food_merchant_payouts (merchant_id, period_start, period_end, orders_count, gross_amount, commission_amount, net_payout, status)
VALUES (?,?,?,?,?,?,?,'pending')"
)->execute([
$merchantId, $periodStart, $periodEnd, $summary['orders_count'],
$summary['gross_amount'], $summary['commission_amount'], $netPayout,
]);
jsonSuccess([
'merchant_id' => $merchantId,
'orders_count' => (int)$summary['orders_count'],
'gross_amount' => (int)$summary['gross_amount'],
'commission_amount' => (int)$summary['commission_amount'],
'net_payout' => $netPayout,
'status' => 'pending',
], 'Payout report generated');