getMessage() . "\n"); exit(1); } $sharedKey = getenv('S2S_SHARED_KEY') ?: ''; if ($sharedKey === '') { // ‏بلا مفتاح لن ينجح أي نداء. الخروج المبكر أوضح من مئة محاولة // ‏تعود 401 وتزيد `attempts` بلا سبب حقيقي. fwrite(STDERR, "[obligation] S2S_SHARED_KEY غير معرّف — لا تسوية\n"); exit(1); } /** * ‏ينادي نقطة الخصم ويُرجع ما خُصم فعلاً، أو null عند فشل لا يمكن تفسيره. * * ‏التمييز بين «خُصم صفر» و«فشل» جوهري: الأول حالة طبيعية (لا أرباح * ‏اليوم) تُسجَّل محاولةً، والثاني لا يجوز أن يُحسب محاولة — وإلا ضاع * ‏يوم تحصيل بسبب عطل شبكي عابر. */ function obligationDeduct(string $ref, string $driverId, string $productCode, float $amount, float $capPercent): ?float { $payload = obligationWalletCall( '/v2/main/ride/driverWallet/deduct_s2s_obligation.php', [ 'settlement_ref' => $ref, 'driverID' => $driverId, 'product_code' => $productCode, 'amount' => number_format($amount, 3, '.', ''), 'cap_percent' => number_format($capPercent, 2, '.', ''), ] ); if ($payload === null || !isset($payload['deducted'])) { error_log("[obligation] تعذّر الحصول على نتيجة خصم ($ref)"); return null; } return round((float) $payload['deducted'], 3); } $today = date('Y-m-d'); $settled = 0; $partial = 0; $idle = 0; $failed = 0; $total = 0.0; try { // ‏الترتيب مقصود: السائق أولاً ليكون سقفه اليومي مُستهلَكاً بترتيب // ‏معلوم، ثم أولوية المنتج (التأمين قبل الوقود قبل الصيانة)، ثم // ‏الأقدم فالأحدث — الدَّين الأقدم أولى بالسداد. $st = $con->query(" SELECT l.id, l.obligation_id, l.driver_id, l.product_code, l.amount_remaining, l.amount_collected, l.amount, l.attempts, p.daily_cap_percent, p.kind FROM obligation_ledger l JOIN driver_obligations o ON o.id = l.obligation_id JOIN obligation_products p ON p.id = o.product_id WHERE l.status IN ('pending', 'partial') AND l.amount_remaining > 0 AND (l.last_attempt_on IS NULL OR l.last_attempt_on <> CURDATE()) ORDER BY l.driver_id ASC, p.priority ASC, l.charge_date ASC "); foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $row) { $ledgerId = (int) $row['id']; $remaining = round((float) $row['amount_remaining'], 3); // ‏المرجع مبنيّ على عدّاد المحاولات لا على تاريخ اليوم. الفرق ليس // ‏تجميلياً: `attempts` لا يزيد إلا بعد تحديث الدفتر بنجاح، فنداء // ‏نجح في المحفظة وضاع ردّه يُعاد لاحقاً — ولو بعد أيام — بالمرجع // ‏نفسه، فتُرجع المحفظة خصمه الأول بدل تنفيذ خصم ثانٍ. مرجعٌ // ‏مبنيّ على التاريخ كان سيخصم المبلغ مرتين في هذه الحالة بالضبط. $ref = "obl_{$ledgerId}_{$row['attempts']}"; $deducted = obligationDeduct( $ref, $row['driver_id'], $row['product_code'], $remaining, (float) $row['daily_cap_percent'] ); if ($deducted === null) { // ‏لا نلمس القيد إطلاقاً — ولا حتى `attempts`. قد يكون الخصم // ‏تمّ في المحفظة وضاع الرد، وإبقاء العدّاد كما هو يضمن أن // ‏المحاولة القادمة تحمل المرجع نفسه فتستردّ نتيجة الأولى. $failed++; continue; } $collected = round((float) $row['amount_collected'] + $deducted, 3); $newRemaining = round((float) $row['amount'] - $collected, 3); if ($newRemaining < 0) { $newRemaining = 0.0; } if ($newRemaining <= 0) { $status = 'settled'; } elseif ($collected > 0) { $status = 'partial'; } else { $status = 'pending'; } try { $con->beginTransaction(); $con->prepare(" UPDATE obligation_ledger SET amount_collected = ?, amount_remaining = ?, status = ?, attempts = attempts + 1, last_attempt_on = ?, settled_at = CASE WHEN ? = 'settled' THEN NOW() ELSE settled_at END WHERE id = ? ")->execute([$collected, $newRemaining, $status, $today, $status, $ledgerId]); // ‏المقسّط وحده يراكم أصلاً مسدَّداً. الدوري بلا نهاية فلا // ‏معنى لأصلٍ ينفد عنده. if ($deducted > 0 && $row['kind'] === 'installment') { $con->prepare(" UPDATE driver_obligations SET principal_paid = principal_paid + ? WHERE id = ? ")->execute([$deducted, (int) $row['obligation_id']]); // ‏سداد الأصل كاملاً يُنهي الالتزام. بدون هذا يبقى قرض // ‏مسدَّد «نشطاً» فيظهر للسائق ويزاحم غيره على السقف. $con->prepare(" UPDATE driver_obligations SET status = 'completed', ended_at = CURDATE() WHERE id = ? AND status = 'active' AND principal_amount > 0 AND principal_paid >= principal_amount ")->execute([(int) $row['obligation_id']]); } $con->commit(); } catch (PDOException $e) { if ($con->inTransaction()) $con->rollBack(); // ‏الخصم تمّ فعلاً والقيد لم يُحدَّث — أخطر حالة هنا. لا إعادة // ‏محاولة تلقائية: المرجع نفسه سيُرجع الخصم الأول لا خصماً // ‏جديداً، لكن التباين بين الدفترين يحتاج عيناً بشرية. error_log("[obligation] خُصم {$deducted} للقيد #{$ledgerId} ولم يُحدَّث الدفتر: " . $e->getMessage()); $failed++; continue; } $total += $deducted; if ($status === 'settled') $settled++; elseif ($deducted > 0) $partial++; else $idle++; } } catch (PDOException $e) { fwrite(STDERR, '[obligation] فشل المرور: ' . $e->getMessage() . "\n"); exit(1); } printf( "[obligation] سُوِّي %d، جزئي %d، بلا خصم %d، فشل %d — إجمالي %.3f\n", $settled, $partial, $idle, $failed, $total );