Update: 2026-08-08 12:58:16
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
<?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
|
||||
);
|
||||
Reference in New Issue
Block a user