Update: 2026-08-08 12:58:16

This commit is contained in:
Hamza-Ayed
2026-08-08 12:58:17 +03:00
parent 611b5e616d
commit fae0e4a38a
55 changed files with 5771 additions and 1820 deletions
+32 -18
View File
@@ -7,11 +7,19 @@
* ‏رصيد السائق يعيش في payment_server لا في هذه القاعدة، والخصم
* ‏المباشر من هنا كان سيعني كتابةً عابرة للحدود بلا معاملة موحّدة —
* ‏أي خطر رصيد ناقص بلا قيد يقابله، أو قيد بلا خصم. فنقيّد استحقاقاً
* ‏هنا، وتقرأه التسوية.
* ‏هنا، ويقرأه محرك التسوية `cron_obligation_settlement.php`.
*
* ‏تحديث (٢٠٢٦-٠٨-٠٨): صار هذا الملف **مُصدِر استحقاق** في محرك
* ‏الالتزامات العام، فيكتب في `obligation_ledger` لا في
* ‏`insurance_premium_ledger`. سبب النقل أن الدفتر القديم لم يكن يقرأه
* ‏أحد إطلاقاً — «تقرأه التسوية» كان وعداً بلا منفّذ، وكل قسط قُيّد منذ
* ‏الإطلاق بقي معلّقاً بلا تحصيل. جداول التأمين تبقى كما هي شاهداً
* ‏تاريخياً، وقد رُحّلت بياناتها في
* ‏`migrations/2026_08_08_obligations_engine.sql`.
*
* ‏الحماية من الاحتساب المزدوج طبقتان:
* ١. `last_charged_on` في الوثيقة — نفلتر به قبل العمل
* ٢. مفتاح فريد (policy_id, charge_date) في الدفتر — يحسم السباق
* ١. `last_charged_on` في الالتزام — نفلتر به قبل العمل
* ٢. مفتاح فريد (obligation_id, charge_date) في الدفتر — يحسم السباق
* لو عملت نسختان من الكرون معاً
*
* ‏الأولى وحدها لا تكفي: بين القراءة والكتابة نافذة، والمفتاح الفريد
@@ -39,19 +47,24 @@ $charged = 0;
$skipped = 0;
try {
// ‏المصدر الآن الالتزامات لا الوثائق. القيد على `kind = 'recurring'`
// ‏لا على المنتج التأميني تحديداً: أي منتج دوري يُضاف لاحقاً يُقيَّد
// ‏بالمنطق نفسه بلا سطر جديد هنا.
$st = $con->query("
SELECT p.id, p.driver_id, p.last_charged_on,
pl.premium, pl.currency, pl.billing_cycle
FROM driver_insurance_policies p
JOIN insurance_plans pl ON pl.id = p.plan_id
WHERE p.status = 'active'
ORDER BY p.id ASC
SELECT o.id, o.driver_id, o.last_charged_on, o.cycle_amount,
pr.code AS product_code, pr.currency, pr.billing_cycle
FROM driver_obligations o
JOIN obligation_products pr ON pr.id = o.product_id
WHERE o.status = 'active'
AND pr.kind = 'recurring'
AND pr.billing_cycle <> 'none'
ORDER BY o.id ASC
");
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $policy) {
$chargeDate = $policy['billing_cycle'] === 'monthly' ? $monthStart : $today;
// ‏الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل وثيقة كل يوم.
// ‏الفلترة الرخيصة أولاً — تجنّبنا محاولة إدراج لكل التزام كل يوم.
if ($policy['last_charged_on'] === $chargeDate) {
$skipped++;
continue;
@@ -61,16 +74,17 @@ try {
$con->beginTransaction();
$con->prepare("
INSERT INTO insurance_premium_ledger
(policy_id, driver_id, charge_date, amount, currency, status)
VALUES (?, ?, ?, ?, ?, 'pending')
INSERT INTO obligation_ledger
(obligation_id, driver_id, product_code, charge_date,
amount, amount_collected, amount_remaining, currency, status)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, 'pending')
")->execute([
$policy['id'], $policy['driver_id'], $chargeDate,
$policy['premium'], $policy['currency'],
$policy['id'], $policy['driver_id'], $policy['product_code'], $chargeDate,
$policy['cycle_amount'], $policy['cycle_amount'], $policy['currency'],
]);
$con->prepare("
UPDATE driver_insurance_policies SET last_charged_on = ? WHERE id = ?
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
")->execute([$chargeDate, $policy['id']]);
$con->commit();
@@ -82,11 +96,11 @@ try {
// ‏هذه ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه.
if ($e->getCode() === '23000') {
$con->prepare("
UPDATE driver_insurance_policies SET last_charged_on = ? WHERE id = ?
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
")->execute([$chargeDate, $policy['id']]);
$skipped++;
} else {
error_log("[insurance] تعذّر قيد قسط الوثيقة #{$policy['id']}: " . $e->getMessage());
error_log("[insurance] تعذّر قيد استحقاق الالتزام #{$policy['id']}: " . $e->getMessage());
}
}
}
+199
View File
@@ -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
);