Files
tripz-llc/backend/bot/cron_obligation_settlement.php
2026-08-09 16:56:13 +03:00

200 lines
9.8 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* cron_obligation_settlement.php — محرك تسوية الالتزامات
* ─────────────────────────────────────────────────────────────
* ‏الجهة **الوحيدة** التي تحوّل قيداً في `obligation_ledger` إلى خصم فعلي
* ‏من محفظة السائق. مُصدِرو الاستحقاق (التأمين، الوقود، الصيانة) يقيّدون
* ‏فقط ولا يلمسون مالاً — وهذا ما يجعل إضافة منتج جديد لا تمسّ أخطر
* ‏منطق في المنصة.
*
* ‏هذا الملف يغلق ثغرة إنتاج قائمة: `cron_insurance_premiums.php` يقيّد
* ‏الأقساط منذ إطلاقه وتعليقه يقول «تقرأه التسوية» — ولم تكن التسوية
* ‏موجودة. كل قسط قُيّد حتى اليوم بقي `pending` بلا تحصيل.
*
* ‏لماذا كرون منفصل لا خصم عند إنهاء الرحلة؟
* ‏لأن `finish_ride_updates.php` مسار حرج والسائق ينتظر رده. نداء شبكي
* ‏إلى سيرفر المحفظة داخله يعني تعليق شاشة السائق على بطء طرف ثالث،
* ‏وفشلاً في التسوية يظهر للسائق كفشل في إنهاء رحلته.
*
* ‏ضمانات عدم التكرار طبقتان:
* ١. `last_attempt_on` في القيد — محاولة واحدة لكل قيد في اليوم
* ٢. `settlement_ref` فريد في سيرفر المحفظة — يحسم السباق ويجعل
* ‏إعادة النداء بعد انقطاع شبكي آمنة بالتعريف
*
* ‏الأولى وحدها لا تكفي: نداء قد ينجح في المحفظة ثم ينقطع قبل أن يصل
* ‏الرد، فيبقى القيد بلا تحديث. الطبقة الثانية تجعل إعادة المحاولة تُرجع
* ‏نتيجة الخصم الأول بدل خصم ثانٍ.
*
* ‏جدولة: مرة يومياً في آخر اليوم لا بعد قيد الاستحقاقات مباشرة —
* ‏السقف نسبة من أرباح اليوم، وقراءتها بعد منتصف الليل تعني سقفاً صفرياً
* ‏كل ليلة وتحصيلاً لا يقع أبداً.
* 30 23 * * * docker compose exec -T php php /var/www/backend/bot/cron_obligation_settlement.php
*/
require_once __DIR__ . '/../core/bootstrap.php';
require_once __DIR__ . '/../obligations/functions.php';
try {
$con = Database::get('main');
} catch (Exception $e) {
fwrite(STDERR, '[obligation] DB unavailable: ' . $e->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
);