Update: 2026-08-07 05:35:52
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;
|
||||||
|
}
|
||||||
@@ -24,9 +24,15 @@ if ($order['payment_method'] === 'wallet') {
|
|||||||
)->execute([$orderId, $courierOwed]);
|
)->execute([$orderId, $courierOwed]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// قيد أرباح السائق (أجرة التوصيل) — مُتراكم، يُسوَّى عبر دورة تسوية منفصلة
|
// قيد أرباح السائق (أجرة التوصيل) — مُتراكم عمداً بقرار المالك: الأجرة
|
||||||
// (لا نقتطع فعلياً من/إلى محفظة السائق هنا: عقد S2S المؤكد فقط لتحويلات
|
// تُصرف دورياً مع مقاصّة دَين النقد، لا فورياً عند كل تسليم. صرفها منفصلة
|
||||||
// سائق↔سائق (driverWallet/transfer.php) لا لإيداع أرباح من المنصة مباشرة).
|
// عن الدَين يعني الدفع للسائق بينما هو يحمل مال المنصة من الطلبات النقدية.
|
||||||
|
//
|
||||||
|
// التسوية في food/admin/courier_settlement.php.
|
||||||
|
//
|
||||||
|
// (تصحيح لملاحظة سابقة هنا: عقد S2S لإيداع أرباح من المنصة **موجود
|
||||||
|
// ومؤكد** — driverWallet/add_s2s_reward.php، وهو مستخدم في تعويض عدم
|
||||||
|
// الحضور ورسوم الإلغاء وتعويضات الشكاوى.)
|
||||||
$food_con->prepare(
|
$food_con->prepare(
|
||||||
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'courier_payout',?,'pending')"
|
"INSERT INTO food_order_payments (order_id, type, amount, status) VALUES (?,'courier_payout',?,'pending')"
|
||||||
)->execute([$orderId, (int)$order['delivery_fee']]);
|
)->execute([$orderId, (int)$order['delivery_fee']]);
|
||||||
|
|||||||
@@ -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`;
|
||||||
Reference in New Issue
Block a user