# محرك الالتزامات العام — تصميم وتنفيذ > بند 3.4 من [دراسة الفرص](SIRO_OPPORTUNITY_STUDY_AR.md) — اقتصاد السائق: الوقود، الصيانة، والتمويل. > تاريخ التنفيذ: 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` − الدَّين القائم − القسائم السارية. حجز القسائم يُطرح كالدَّين تماماً: قسيمة قد تُصرف بعد لحظة، والسماح بإصدار قسائم تتجاوز السقف يعني سائقاً يتزوّد بضعف ما يستحق. --- ## الخطوات التالية 4. الصيانة بالتقسيط (`installment`) — المحرك يدعمها بالكامل، ينقصها منتج ومسار ورشة 5. مؤشر الجدارة الائتمانية — قراءة فقط، بلا التزام مالي، تمهيداً لشريك التمويل 6. رصد تحوّل السائق من المحفظة إلى الكاش بعد فتح التزامه (انظر الحدود المعروفة) تحذير الدراسة قائم: أي تمويل حقيقي يمرّ عبر شريك مرخّص، لا من ميزانية سيرو.