Files
Siro/docs/04_features/OBLIGATIONS_ENGINE_DESIGN.md

179 lines
16 KiB
Markdown

# محرك الالتزامات العام — تصميم وتنفيذ
> بند 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. رصد تحوّل السائق من المحفظة إلى الكاش بعد فتح التزامه (انظر الحدود المعروفة)
تحذير الدراسة قائم: أي تمويل حقيقي يمرّ عبر شريك مرخّص، لا من ميزانية سيرو.