diff --git a/backend/food/admin/courier_settlement.php b/backend/food/admin/courier_settlement.php new file mode 100644 index 00000000..495b2440 --- /dev/null +++ b/backend/food/admin/courier_settlement.php @@ -0,0 +1,231 @@ +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; +} diff --git a/backend/food/courier/delivered.php b/backend/food/courier/delivered.php index f15f4a29..c6bf17fd 100644 --- a/backend/food/courier/delivered.php +++ b/backend/food/courier/delivered.php @@ -24,9 +24,15 @@ if ($order['payment_method'] === 'wallet') { )->execute([$orderId, $courierOwed]); } -// قيد أرباح السائق (أجرة التوصيل) — مُتراكم، يُسوَّى عبر دورة تسوية منفصلة -// (لا نقتطع فعلياً من/إلى محفظة السائق هنا: عقد S2S المؤكد فقط لتحويلات -// سائق↔سائق (driverWallet/transfer.php) لا لإيداع أرباح من المنصة مباشرة). +// قيد أرباح السائق (أجرة التوصيل) — مُتراكم عمداً بقرار المالك: الأجرة +// تُصرف دورياً مع مقاصّة دَين النقد، لا فورياً عند كل تسليم. صرفها منفصلة +// عن الدَين يعني الدفع للسائق بينما هو يحمل مال المنصة من الطلبات النقدية. +// +// ‏التسوية في 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']]); diff --git a/backend/food/migrations/2026_08_07_courier_settlement.sql b/backend/food/migrations/2026_08_07_courier_settlement.sql new file mode 100644 index 00000000..35a7974d --- /dev/null +++ b/backend/food/migrations/2026_08_07_courier_settlement.sql @@ -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`;