200 lines
9.8 KiB
PHP
200 lines
9.8 KiB
PHP
<?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
|
||
);
|