Files
tripz-llc/backend/food/admin/courier_settlement.php
T
2026-08-09 16:56:13 +03:00

232 lines
10 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}