16 KiB
محرك الالتزامات العام — تصميم وتنفيذ
بند 3.4 من دراسة الفرص — اقتصاد السائق: الوقود، الصيانة، والتمويل. تاريخ التنفيذ: 2026-08-08. الحالة: الخطوتان ١ و٢ منفَّذتان (الجداول + التسوية).
لماذا الآن — ثغرة قائمة لا تحسين مستقبلي
cron_insurance_premiums.php يقيّد أقساط التأمين منذ إطلاقه، وتعليقه يقول إن الدفتر «تقرأه التسوية». لم تكن التسوية موجودة. لا مرجع واحد لجدول insurance_premium_ledger في المشروع كله خارج الكرون والـmigration.
النتيجة العملية: كل قسط تأمين قُيّد حتى اليوم بقي بحالة pending، ولم يُحصَّل منه شيء.
الخيار كان: كتابة تسوية خاصة بالتأمين، أو تعميم النموذج مرة واحدة. والوقود والصيانة والتمويل — البنود الثلاثة التالية في اقتصاد السائق — كلها نفس الشكل: التزام دوري أو مقسّط يُخصم من أرباح السائق. كتابة تسوية لكل منها تعني أربع نسخ من أخطر منطق في المنصة: الذي يلمس مال السائق.
البنية
فصل ثلاث مسؤوليات كان الكود يخلطها:
| المسؤولية | الملف | ماذا يفعل |
|---|---|---|
| مُصدِر الاستحقاق | bot/cron_insurance_premiums.php وما يليه |
يقيّد «على السائق كذا». لا يلمس مالاً |
| الدفتر الموحّد | obligation_ledger |
سجل دائم واحد مهما كان المصدر |
| محرك التسوية | bot/cron_obligation_settlement.php |
الجهة الوحيدة التي تخصم من الرصيد |
منتج جديد = صفّ في obligation_products + مُصدِر استحقاق صغير. لا يلمس التسوية ولا الرصيد إطلاقاً.
الجداول
| الجديد | يعمّم |
|---|---|
obligation_products |
insurance_plans |
driver_obligations |
driver_insurance_policies |
obligation_ledger |
insurance_premium_ledger |
obligation_settlements (قاعدة المحفظة) |
— جديد كلياً |
ثلاثة فروق جوهرية عن نموذج التأمين:
kind يحدّد سلوك الاستحقاق: recurring يتكرّر بلا نهاية (تأمين)، installment له أصل ينتهي بسداده (صيانة، تمويل)، drawdown يُقيَّد عند السحب لا على جدول (وقود).
amount_collected / amount_remaining في الدفتر. القسط يُدفع كاملاً أو لا يُدفع، أما الوقود والصيانة فتحصيلهما جزئي بطبعه: قيدٌ بقيمة عشرة قد يُحصَّل على ثلاثة أيام. بلا هذين العمودين لا يمكن تمثيل ذلك إلا بتفتيت القيد، فيضيع أثر الاستحقاق الأصلي.
priority لترتيب المزاحمة: التأمين (١٠) قبل الوقود (٢٠) قبل الصيانة (٣٠). انقطاع التأمين يُلغي وثيقة ويفقد السائق تغطيته؛ تأخّر قسط صيانة يوماً لا يكلّف أحداً شيئاً.
سقف الخصم اليومي — القرار الذي يقرّر نجاح الميزة
الخصم نسبة من أرباح اليوم لا مبلغ ثابت، بسقف على مستوى المنتج (daily_cap_percent، افتراضي ٢٥٪) وأرضية للرصيد (OBLIGATION_MIN_BALANCE، افتراضي صفر).
السبب: سائق عليه قسط تأمين وقرض صيانة في يوم ضعيف سيفتح التطبيق ويجد صفراً — ويهجر المنصة. المتبقي يُرحَّل، لا يُسقَط.
الأساس هو دخل اليوم لا الرصيد المتراكم: الخصم من رصيد قديم يفاجئ سائقاً لم يعمل اليوم، والخصم من دخل حاضر يبقى محسوساً كاقتطاع من كسبٍ وقع للتوّ.
السقف مشترك بين المنتجات: نقطة الخصم تطرح ما اقتُطع اليوم من كل المنتجات قبل حساب المتاح. بدون هذا الطرح تصير ثلاثة منتجات بسقف ٢٥٪ تأخذ ٧٥٪ من يوم السائق.
بوابة الائتمان — لا التزام إلا لمن تمرّ أرباحه بالمحفظة
قرار المالك (2026-08-09). سببه المباشر أن سقف الخصم نسبة من الدخل المارّ بالمحفظة: سائق الكاش الذي يحصّل نقداً سقفه صفر كل يوم، فلا يُحصَّل منه شيء أبداً. منحه وقوداً أو صيانة بالدَّين تسليمُ قيمةٍ فعلية مقابل قناة سداد لا وجود لها — أي هبة لا ائتمان.
الشرط مخزَّن مع المنتج (requires_wallet_income, min_wallet_days_30d, min_wallet_income_30d) على غرار شروط الرحلات والتقييم القائمة، لأن العتبة تختلف بطبيعة المنتج.
المقياس أيام لا مبلغ. السؤال ليس «كم يكسب؟» بل «هل قناة التحصيل تعمل؟». دخل واحد كبير قد يكون استرداداً أو تسوية حادثة؛ اثنا عشر يوماً متفرّقاً قناةٌ حيّة.
الفشل مغلق. تعذُّر الوصول إلى سيرفر المحفظة يعني رفضاً لا قبولاً. عطلٌ شبكي عابر يؤجّل منح ائتمان، بينما القبول عند الشك يمنحه لمن قد لا يُسترد منه — والخطأ الثاني وحده يكلّف مالاً.
الفحص في طبقتين. نقطة الاستدعاء تفحص لتعرض للسائق سبباً مفهوماً وما ينقصه؛ وobligationOpen تفحص ثانيةً عند الكتابة كحارس أخير. النقطة قد تنسى، أو يُضاف منتج جديد بمسار جديد يُغفلها — والفحص عند الكتابة وحده هو الذي لا يمكن الالتفاف عليه بالسهو.
التأمين يخضع للبوابة بعتبة متساهلة (ثمانية أيام، بلا شرط مبلغ): القسط الذي تتحمّله الشركة ثم تسترده ائتمانٌ بالمعنى نفسه، لكن التأمين مشروط أصلاً بمئتي رحلة وتقييم جيد، وتشديد بوابة ثانية فوقها يفرغ المنتج من غرضه. تغيير العتبة صفٌّ واحد في obligation_products.
الشرط يمسّ الاشتراكات الجديدة وحدها. الالتزامات القائمة لا تُفحص ثانيةً: سحب تغطية من سائق مؤمَّن اليوم بسبب شرط سُنّ اليوم عقوبة بأثر رجعي.
الحد بين الخادمين
الرصيد في payment_server، القيد في القاعدة الرئيسية. لا كتابة عابرة للحدود.
لماذا نقطة خصم جديدة بدل add_s2s_reward.php بمبلغ سالب
add_s2s_reward.php لا يضمن عدم التكرار. لا مفتاح فريد على driverWallet.paymentID، ولا فحص تكرار فيها إلا لتحديات daily_/weekly_. تعليق food/admin/courier_settlement.php يقول إن paymentID «يمنع ازدواج الصرف عند إعادة المحاولة» — وهذا غير صحيح، لا شيء يفرضه.
هذا مقبول تقريباً للإيداعات (إيداع مكرّر يُسترد)، وغير مقبول إطلاقاً للخصومات: محرك التسوية يعيد المحاولة بطبعه.
ولا يمكن ببساطة إضافة مفتاح فريد على driverWallet.paymentID: الجدول يحمل سنوات من صفوف قد تحمل معرّفات مكرّرة أصلاً. فالضمان بُني خارجه، في obligation_settlements.
المرجع مبنيّ على عدّاد المحاولات لا على التاريخ
settlement_ref = obl_{ledgerId}_{attempts}
attempts لا يزيد إلا بعد تحديث الدفتر بنجاح. فنداء نجح في المحفظة وضاع ردّه في الطريق يُعاد لاحقاً — ولو بعد أيام — بالمرجع نفسه، فتُرجع المحفظة خصمه الأول بدل تنفيذ خصم ثانٍ.
مرجعٌ مبنيّ على التاريخ (obl_{id}_{YYYYMMDD}، وهو ما كُتب أولاً) كان سيصل في اليوم التالي بمرجع جديد ويخصم المبلغ مرتين في هذه الحالة بالضبط.
التوقيت
| الوقت | المهمة |
|---|---|
| 00:15 | مُصدِر الاستحقاق — يقيّد أقساط اليوم |
| 23:30 | محرك التسوية — يحصّل من أرباح اليوم |
التسوية لا تعمل بعد المُصدِر مباشرة. السقف نسبة من أرباح اليوم، وتشغيلها الساعة 00:45 يعني قراءة يومٍ عمره خمس وأربعون دقيقة: أرباحه صفر، فالسقف صفر، فلا يُخصم شيء أبداً. كان المحرك سيعمل كل ليلة بلا خطأ واحد وبلا تحصيل قرش.
23:30 تقرأ يوم عمل مكتملاً تقريباً. النصف ساعة المتبقية ليست ضياعاً: ما لم يُحصَّل يبقى في الدفتر ويُلاحَق غداً.
لا خصم داخل finish_ride_updates.php. المسار حرج والسائق ينتظر ردّه؛ نداء شبكي إلى سيرفر المحفظة داخله يعني تعليق شاشته على بطء طرف ثالث، وفشلاً في التسوية يظهر له كفشل في إنهاء رحلته.
ما نُفِّذ
| الملف | الدور |
|---|---|
backend/migrations/2026_08_08_obligations_engine.sql |
الجداول الثلاثة + ترحيل التأمين |
backend/migrations/2026_08_09_obligation_credit_gate.sql |
أعمدة بوابة الائتمان |
payment_server/v2/main/ride/driverWallet/income_summary_s2s.php |
ملخّص الدخل المارّ بالمحفظة |
payment_server/migrations/2026_08_08_obligation_settlements.sql |
سجل الخصومات (ضمان عدم التكرار) |
payment_server/v2/main/ride/driverWallet/deduct_s2s_obligation.php |
نقطة الخصم — سقف + عدم تكرار |
backend/bot/cron_obligation_settlement.php |
محرك التسوية |
backend/obligations/functions.php |
فتح/إغلاق التزام وقراءة المستحق |
backend/bot/cron_insurance_premiums.php |
صار مُصدِر استحقاق عاماً |
backend/driver_assurance/{subscribe,cancel,get,plans}.php |
ربط بالدفتر الموحّد + بوابة الائتمان |
docker/crontab.production |
جدولة التسوية |
الترحيل
جداول التأمين تبقى كما هي — لا تُحذف ولا تُعدَّل، وتصير شاهداً تاريخياً. بياناتها تُنقل إلى النموذج العام، والقيود المعلّقة (وهي مال حقيقي تراكم بلا تحصيل) تصير مرئية لمحرك التسوية.
القيود القديمة بحالة failed تعود pending: الفشل السابق لم يكن قراراً بل غياب محرك، وحجبها الآن يعني إسقاط مال مستحق فعلاً.
قسم الترحيل يعمل أكثر من مرة بلا ضرر — كل إدراج مشروط بعدم وجود ما يقابله.
لماذا لم يكفِ الترحيل وحده: driver_assurance/subscribe.php كان يكتب في جداوله الخاصة فقط. بقاؤه كذلك كان يعني أن كل اشتراك جديد لا يظهر في الدفتر الموحّد — أي لا يُقيَّد له قسط ولا يُحصَّل منه شيء. الترحيل يعالج الماضي؛ ربط subscribe/cancel يمنع تكرار الثغرة مستقبلاً.
حدود معروفة — تُحسم قبل الخطوة ٤
سائق الكاش محجوب لا محصَّل منه. بوابة الائتمان تمنع منحه التزاماً أصلاً، وهذا هو القرار المتّخذ. لكنها تعالج الدخول لا الخروج: سائق كان دخله يمرّ بالمحفظة ثم تحوّل إلى الكاش بعد فتح التزامه يبقى التزامه قائماً بسقف صفر — يتراكم في الدفتر بلا تحصيل. لا يوجد بعد ما يرصد هذا التحوّل أو ينبّه عليه.
تعارض قيد وحساب. لو خُصم من المحفظة ولم يُحدَّث الدفتر (فشل قاعدة بيانات بعد نجاح النداء)، يُسجَّل السطر في error_log بصيغة صريحة ولا تُعاد المحاولة تلقائياً. المرجع الثابت يمنع الخصم المزدوج، لكن التباين بين الدفترين يحتاج عيناً بشرية. لا توجد بعد لوحة تعرض هذه الحالات.
لم يُختبر مقابل قاعدة بيانات. كل ملفات PHP اجتازت php -l، أما الـmigrations فلم تُنفَّذ — لا MySQL ولا Docker على جهاز التطوير هذا. يجب تشغيلها على بيئة اختبار قبل الإنتاج.
محفظة الوقود (الخطوة ٣ — منفَّذة)
أول منتج من نوع drawdown: لا دورة فوترة، والاستحقاق يُقيَّد لحظة الصرف من المحطة.
| الملف | الدور |
|---|---|
backend/migrations/2026_08_10_fuel_wallet.sql |
source_ref + credit_limit + المحطات + القسائم + المنتج |
backend/fuel/functions.php |
حساب السقف وتوليد الرموز |
backend/fuel/{enroll,get,stations,request_voucher,cancel_voucher}.php |
مسار السائق |
backend/fuel/redeem.php |
مُصدِر الاستحقاق — تأكيد المحطة |
تعديل على المخطّط اقتضاه المنتج: المفتاح الفريد (obligation_id, charge_date) كان يمنع قيدين لالتزام واحد في يوم واحد — وهو المطلوب للتأمين، وكاسرٌ للوقود: سائق يتزوّد مرتين في يوم يُرفض قيده الثاني فيحصل على وقود بلا دَين. أُضيف source_ref إلى المفتاح، فارغاً للمنتجات الدورية (فيبقى ضمانها حرفياً) ومملوءاً برقم القسيمة للوقود. سلسلة فارغة لا NULL: MySQL يعتبر كل NULL مختلفاً، فعمود قابل للتفريغ كان سيلغي الحماية عن التأمين بصمت.
credit_limit لا principal_amount: الأصل ينفد بالسداد (قرض صيانة)، وسقف الوقود متجدّد — يُسدَّد فيعود متاحاً.
السقف المتاح = credit_limit − الدَّين القائم − القسائم السارية. حجز القسائم يُطرح كالدَّين تماماً: قسيمة قد تُصرف بعد لحظة، والسماح بإصدار قسائم تتجاوز السقف يعني سائقاً يتزوّد بضعف ما يستحق.
الخطوات التالية
- الصيانة بالتقسيط (
installment) — المحرك يدعمها بالكامل، ينقصها منتج ومسار ورشة - مؤشر الجدارة الائتمانية — قراءة فقط، بلا التزام مالي، تمهيداً لشريك التمويل
- رصد تحوّل السائق من المحفظة إلى الكاش بعد فتح التزامه (انظر الحدود المعروفة)
تحذير الدراسة قائم: أي تمويل حقيقي يمرّ عبر شريك مرخّص، لا من ميزانية سيرو.