113 lines
5.4 KiB
PHP
113 lines
5.4 KiB
PHP
<?php
|
||
/**
|
||
* cron_insurance_premiums.php — قيد أقساط التأمين
|
||
* ─────────────────────────────────────────────────────────────
|
||
* يقيّد قسط كل وثيقة نشطة بلغت دورتها. لا يخصم من رصيد أحد:
|
||
*
|
||
* رصيد السائق يعيش في payment_server لا في هذه القاعدة، والخصم
|
||
* المباشر من هنا كان سيعني كتابةً عابرة للحدود بلا معاملة موحّدة —
|
||
* أي خطر رصيد ناقص بلا قيد يقابله، أو قيد بلا خصم. فنقيّد استحقاقاً
|
||
* هنا، ويقرأه محرك التسوية `cron_obligation_settlement.php`.
|
||
*
|
||
* تحديث (٢٠٢٦-٠٨-٠٨): صار هذا الملف **مُصدِر استحقاق** في محرك
|
||
* الالتزامات العام، فيكتب في `obligation_ledger` لا في
|
||
* `insurance_premium_ledger`. سبب النقل أن الدفتر القديم لم يكن يقرأه
|
||
* أحد إطلاقاً — «تقرأه التسوية» كان وعداً بلا منفّذ، وكل قسط قُيّد منذ
|
||
* الإطلاق بقي معلّقاً بلا تحصيل. جداول التأمين تبقى كما هي شاهداً
|
||
* تاريخياً، وقد رُحّلت بياناتها في
|
||
* `migrations/2026_08_08_obligations_engine.sql`.
|
||
*
|
||
* الحماية من الاحتساب المزدوج طبقتان:
|
||
* ١. `last_charged_on` في الالتزام — نفلتر به قبل العمل
|
||
* ٢. مفتاح فريد (obligation_id, charge_date) في الدفتر — يحسم السباق
|
||
* لو عملت نسختان من الكرون معاً
|
||
*
|
||
* الأولى وحدها لا تكفي: بين القراءة والكتابة نافذة، والمفتاح الفريد
|
||
* هو ما يجعل تكرار القيد مستحيلاً لا نادراً.
|
||
*
|
||
* جدولة: مرة يومياً بعد منتصف الليل.
|
||
* 15 0 * * * docker compose exec -T php php /var/www/backend/bot/cron_insurance_premiums.php
|
||
*/
|
||
|
||
require_once __DIR__ . '/../core/bootstrap.php';
|
||
|
||
try {
|
||
$con = Database::get('main');
|
||
} catch (Exception $e) {
|
||
fwrite(STDERR, '[insurance] DB unavailable: ' . $e->getMessage() . "\n");
|
||
exit(1);
|
||
}
|
||
|
||
$today = date('Y-m-d');
|
||
// الشهري يُقيَّد على أول الشهر لا على يوم الاشتراك: دفتر تتوزّع فيه
|
||
// تواريخ القيد على ثلاثين يوماً يستحيل تسويته مع الشريك.
|
||
$monthStart = date('Y-m-01');
|
||
|
||
$charged = 0;
|
||
$skipped = 0;
|
||
|
||
try {
|
||
// المصدر الآن الالتزامات لا الوثائق. القيد على `kind = 'recurring'`
|
||
// لا على المنتج التأميني تحديداً: أي منتج دوري يُضاف لاحقاً يُقيَّد
|
||
// بالمنطق نفسه بلا سطر جديد هنا.
|
||
$st = $con->query("
|
||
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;
|
||
}
|
||
|
||
try {
|
||
$con->beginTransaction();
|
||
|
||
$con->prepare("
|
||
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'], $policy['product_code'], $chargeDate,
|
||
$policy['cycle_amount'], $policy['cycle_amount'], $policy['currency'],
|
||
]);
|
||
|
||
$con->prepare("
|
||
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
|
||
")->execute([$chargeDate, $policy['id']]);
|
||
|
||
$con->commit();
|
||
$charged++;
|
||
} catch (PDOException $e) {
|
||
if ($con->inTransaction()) $con->rollBack();
|
||
|
||
// 23000 = خرق المفتاح الفريد: نسخة أخرى سبقتنا للقيد نفسه.
|
||
// هذه ليست حالة خطأ — هي بالضبط ما صُمّم المفتاح لمنعه.
|
||
if ($e->getCode() === '23000') {
|
||
$con->prepare("
|
||
UPDATE driver_obligations SET last_charged_on = ? WHERE id = ?
|
||
")->execute([$chargeDate, $policy['id']]);
|
||
$skipped++;
|
||
} else {
|
||
error_log("[insurance] تعذّر قيد استحقاق الالتزام #{$policy['id']}: " . $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
} catch (PDOException $e) {
|
||
fwrite(STDERR, '[insurance] فشل المرور: ' . $e->getMessage() . "\n");
|
||
exit(1);
|
||
}
|
||
|
||
echo "[insurance] قُيِّد {$charged} قسطاً، تُخطّي {$skipped}\n";
|