406 lines
61 KiB
Markdown
406 lines
61 KiB
Markdown
# 17 — Backlog الباك إند (مراجعة المالك + مقارنة سيرو)
|
||
|
||
> مصدره: مراجعة المالك (2026-07-17) + قراءة مباشرة لجداول سيرو (`schema_primary.sql`, `schema_ride.sql`).
|
||
> الترتيب حسب الأثر. نمشي **مجموعة مجموعة**.
|
||
|
||
---
|
||
|
||
## المجموعة A — الزمن الحقيقي والإشعارات (الأعلى أولوية) — ✅ منفَّذة
|
||
> شكوى المالك الأساسية: «تسأل القاعدة كثيراً، أقرب للـ polling»، والإشعارات ناقصة.
|
||
|
||
| # | البند | التفصيل | الحالة |
|
||
|---|-------|---------|--------|
|
||
| A1 | **FCM على كل حالات الرحلة** | حالياً push عند `assigned` فقط. المطلوب: FCM **+** WebSocket على **كل** انتقال. ضروري للخلفية (background). | ✅ `notifyParties` تُنادى من كل انتقال + `priority: high` + حذف التوكنات الميتة |
|
||
| A2 | **ترجمة الإشعارات** | الإشعارات تُرسل إنجليزي والهاتف عربي → نصوص الإشعارات في **ملفات ترجمة** وتُرسل حسب لغة المستخدم. | ✅ `common/i18n` (ar/en) + عمود `users.language` |
|
||
| A3 | **تقليل استعلامات القاعدة** | حالة الرحلة الجارية + المواقع في **Redis**؛ القاعدة للحقيقة الدائمة فقط. (الآن كل انتقال يقرأ/يكتب عدة مرات + يقرأ السائق ثانيةً). | ✅ `TripStateService` (hash لكل رحلة نشطة، TTL 6س) — الانتقال صار UPDATE شرطي + قراءة واحدة |
|
||
| A4 | **Race condition عند القبول** | سائقان يقبلان بنفس اللحظة → **قبول ذرّي** (Redis SETNX / UPDATE شرطي `WHERE status='searching'`). أول قبول يفوز، والثاني يُرفض بوضوح. | ✅ CAS بـLua في Redis + `UPDATE … WHERE status='searching'` كحَكَم نهائي |
|
||
| A5 | **إلغاء العرض عند القبول** | فور القبول: بث WebSocket **+ FCM** لبقية السائقين المعروض عليهم → «الرحلة لم تعد متاحة» فتختفي من شاشتهم/الـ overlay. | ✅ مجموعة عروض في Redis + `trip:offer_taken` + FCM |
|
||
| A6 | **الرحلات المتاحة (available rides)** | قائمة طلبات متاحة يسحبها السائق (بديل/مكمّل للعرض المباشر). | ✅ `GET /trips/available` |
|
||
| A7 | **overlay أندرويد** | معلومات الرحلة للقبول/الرفض فوق التطبيقات — يحتاج FCM data-message + payload كامل. | ✅ `dataOnly` + payload كامل (نقاط، مسافة، أجرة، فئة، دفع) |
|
||
|
||
**مؤجَّل من A:** سجل الأحداث (`trip_events`) ما زال يُكتب متزامناً داخل الطلب — نقله إلى BullMQ يبقى تحسيناً مفتوحاً (`worker.ts` لا يزال هيكلاً فارغاً).
|
||
|
||
---
|
||
|
||
## المجموعة B — اكتمال نموذج الرحلة — 🔵 الشريحة الأولى منفَّذة (المال والزمن)
|
||
> مقارنة بجدول `ride` في سيرو + قواعد التشغيل.
|
||
|
||
| # | البند | التفصيل | الحالة |
|
||
|---|-------|---------|--------|
|
||
| B1 | **حالة `started` وفصل الوصول عن البدء** | — | ✅ **كان منفَّذاً أصلاً**: `driver_arrived` و`in_progress` حالتان منفصلتان في آلة الحالة منذ P1. الناقص كان الطوابع والانتظار (B2/B7) لا الحالة نفسها. |
|
||
| B2 | **عدّاد انتظار 5 دقائق (قانون)** | يبدأ عند `driver_arrived`، ويُحتسب عند `in_progress`. الدقائق المجانية `free_waiting_min` (5 افتراضاً) قابلة للضبط لكل تعرفة، والزائد بسعر `per_min_waiting` للنافذة الفعّالة. | ✅ `waiting_min` + `waiting_charge` |
|
||
| B3 | **احتساب مشوار الوصول للراكب** | يُقاس من موقع السائق **الحيّ لحظة القبول** حتى نقطة الالتقاط. | 🟡 **القياس ✅ والقاعدة ❌** — راجع التصحيح أدناه |
|
||
| B6 | **فصل السعر** | `price_for_passenger` · `price_for_driver` · `commission_amount` · `commission_rate`. العمولة تُعرَّف في التعرفة: `commission: { percent, flat, min }`، ولا تتجاوز الأجرة أبداً. | ✅ + **تسوية المحفظة صُحّحت**: كانت تُضيف للسائق كامل أجرة الراكب — أي أن العمولة كانت تضيع |
|
||
| B7 | **طوابع زمنية دقيقة** | `driver_going_at` (`DriverIsGoingToPassenger`) · `arrived_at` · `started_at` (`rideTimeStart`) · `completed_at` (`rideTimeFinish`). | ✅ + كشف «الإنهاء السريع» صار يقيس من **بدء** الرحلة لا إسنادها |
|
||
| B4 | **نقاط توقف (stops)** | ✅ `stops` jsonb؛ المسار والتسعير يمرّان بالنقاط بالترتيب (أصل → توقف₁ → … → وجهة) بجمع أرجل المسار. |
|
||
| B5 | **حجز مسبق/جدولة** | ✅ `scheduled_at` + حالة `scheduled` (لا تدخل Redis ولا تُوزَّع). `ScheduledTripsSweeper` **داخل الـAPI** (يملك الـgateway) يطلقها قبيل موعدها بـUPDATE شرطي (آمن عبر النسخ). |
|
||
| B8 | **مطابقة الوجهة** | 🟡 الحقول والخصم منفَّذان: `is_destination_match` + `destination_match_discount` + `destination_match_discount_pct` في التعرفة، يُطبَّق في التسوية (الخصم على الراكب، السائق يقبض المخفَّض كاملاً). **مؤجَّل**: محرّك المطابقة الذي **يضبط** العلم (وضع «وجهة السائق») — يحتاج تخزين وجهة السائق + مطابقة اتجاهية في محرّك التوزيع. |
|
||
| B9 | **تعديل التعرفة من لوحة الأدمن** | جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). | ⏳ |
|
||
| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). | ⏳ |
|
||
| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. | ⏳ |
|
||
|
||
### 🔴 تصحيحان لازمان على الشريحة الأولى (توضيح المالك 2026-07-17)
|
||
بنيتُ B3 وB6 على افتراضات خاطئة. الصواب:
|
||
|
||
**1. مشوار الوصول ليس بنداً في كل أجرة — بل تعويض عن عدم حضور الراكب.**
|
||
> «مشوار الوصول يُحسب إذا انتظر السائق خمس دقائق وأُلغيت الرحلة بعد الخمس دقائق.»
|
||
|
||
فالقاعدة: يُحتسب **فقط** عند الإلغاء من حالة `driver_arrived` بعد انقضاء الدقائق المجانية. بنيتُه كمفتاح تعرفة يُضاف لكل رحلة (`pickup_leg.charge`) — **خطأ**. المطلوب: يحلّ محلّ رسم الإلغاء الثابت في تلك الحالة، ويذهب **للسائق** تعويضاً.
|
||
|
||
**2. العمولة لا تُقتطع من أجرة السائق إطلاقاً** — راجع **[18-driver-credit-commission](18-driver-credit-commission.md)**.
|
||
`price_for_driver = price_for_passenger` (السائق يقبض كاملاً)، والعمولة تُخصم من **رصيد تشغيلي مدفوع سلفاً**. ما نُفِّذ (`price_for_driver = fare − commission`) نموذجُ أوبر، لا نموذجنا.
|
||
|
||
**الأجرة النهائية بعد التصحيح** = السعر المقفول + رسم الانتظار الزائد. ومشوار الوصول يظهر عند الإلغاء فقط.
|
||
|
||
---
|
||
|
||
## المجموعة C — بيانات السائق والمركبة (من سيرو) — ✅ منفَّذة
|
||
|
||
| # | البند | الحالة |
|
||
|---|-------|--------|
|
||
| C1 | **CarRegistration كامل** | ✅ جدول `vehicles` (مكافئ CarRegistration) — `plate`/`vin` (مشفَّران) · `make`/`model`/`year` · **`color`+`color_hex`** · `fuel`/`owner`/`registration_expiry`/`category` · `is_default` · `status`. سائق قد يملك أكثر من مركبة؛ `VehiclesService` يضمن **افتراضية واحدة دائماً** (أول مركبة تلقائياً، حذف الافتراضية يرقّي غيرها). |
|
||
| C2 | **حقول السائق** | ✅ على `drivers`: `gender` · `national_number` (مشفَّر + فهرس أعمى فريد لكل مستأجر، نمط الهاتف) · `name_arabic` (مشفَّر) · `birthdate` · `address` · `license_type/categories/issue/expiry` · `rejected_reason`. عبر `PATCH /drivers/profile`. |
|
||
| C3 | **`ai_data` + `user_input`** | ✅ عمودان jsonb على `drivers` و`vehicles` — مخرجات Gemini الخام مقابل ما أدخله السائق (يُراكَم لا يُستبدَل، للمقارنة حقلاً بحقل). |
|
||
| C4 | **صور السيارة ×2** | ✅ `vehicle_photo` بـ`min: 2` في كتالوج الوثائق (`REQUIRED_DOCS`). |
|
||
| C5 | **فيديو/liveness للوجه** | ✅ نوع وثيقة `face_liveness` جديد — الرفع يقبل الفيديو (لا قيد mime)، ومفصول عن مسار مطابقة Gemini للصور. |
|
||
|
||
**قرار تصميم**: المركبة كيان مستقل (`vehicles`) لا حقول مسطّحة على السائق — سيرو نفسه يفصلها (`CarRegistration` مع `isDefault`)، وسائق واحد قد يبدّل مركبته. حقول `vehicle_make/model/plate/color` القديمة على `drivers` تبقى للتوافق؛ المصدر الجديد هو `vehicles`.
|
||
**مؤجَّل بوعي**: مطابقة الوجه الآلية على `face_liveness` (استخراج إطار + Gemini vision) — البنية جاهزة، والمطابقة الفعلية عند تفعيل مزوّد الرؤية. `accountBank`/`employmentType`/`maritalStatus` من سيرو تُضاف عند الحاجة (ليست حرجة للتشغيل).
|
||
|
||
---
|
||
|
||
## المجموعة D — الأمان والمصادقة — ✅ منفَّذة
|
||
|
||
| # | البند | الحالة |
|
||
|---|-------|--------|
|
||
| D1 | **تطبيع أرقام الهاتف (JO/EG/SY)** | ✅ `common/phone/phone.service.ts`. مفتاح قانوني واحد لكل رقم حقيقي — يقبل صفراً محلياً/دولياً/`+`/`00`/بلا صفر، ويرجع دائماً صيغة دولية بلا `+`. يُطبَّق في `send-otp`/`verify-otp`/`platform/users/role`. هجرة `NormalizePhones` تُصحّح حسابات الاختبار الموجودة على السيرفر (بحذر: تتخطّى أي صفٍّ يتصادم ناتجه مع صفٍّ آخر بدل كسر القيد الفريد). |
|
||
| D2 | **بصمة الجهاز** | 🟡 **مبنيّة ومطفأة** (`AUTH_REQUIRE_DEVICE_BINDING=false`) حتى يرسل فلاتر `x-device-id`. **مُنفَّذة داخل `JwtStrategy` نفسها لا كحارس منفصل** — يُفرض على كل نقطة محميّة تلقائياً، فلا نقطة منسيّة. توكن الدخول يحمل `hash(deviceId)` لا القيمة الخام؛ توكن مسروق من جهاز آخر يُرفض بمجرد تفعيل العلم. |
|
||
| D3 | **HMAC للعمليات الحساسة** | ✅ **منفَّذ ضمن I6** — `SigningService`/`SignatureGuard`، نفس البند لا تكرار. |
|
||
| D4 | **حدّ الطلبات (rate limiting)** | ✅ **إصلاح ثغرة قائمة** — راجع أدناه. |
|
||
| D5 | **OTP متعدد المزوّدين حسب الدولة** | ✅ — راجع أدناه. |
|
||
|
||
### D5 — توجيه OTP حسب الدولة + failover (من سيرو `auth/otp/`)
|
||
مراجعة سيرو أظهرت: مصر تستعمل **Kazumi SMS** (`sms.kazumi.me`) مع failover لواتساب، وسوريا/الأردن **Nabeh** (واتساب، توكن مُخزَّن 24س). عندنا كان مزوّد واحد (Nabeh) فقط.
|
||
- `integrations/otp/` — واجهة `OtpProvider` + `OtpDispatcher` يوجّه حسب `tenant.countryPack` بسلسلة failover (`eg: [kazumi, nabeh]` · `jo/sy: [nabeh]`). إضافة دولة/مزوّد = صنف موفّر + سطر في السلسلة.
|
||
- كل مزوّد يرسل **رمزاً نولّده نحن** (مخزَّن في Redis) — لا يولّده المزوّد. استبعدنا نمط Intaleq (يولّد الرمز ويرجعه) حفاظاً على مصدر واحد.
|
||
- **موحَّد لكل المسارات**: تسجيل الدخول (`AuthService`) والسحب (`PayoutsService`) يمرّان بنفس المُوزِّع الآن — لا استدعاء Nabeh مباشر في أي مكان.
|
||
- **قرار المالك (2026-07-17): بلا كلمة مرور إطلاقاً.** المصادقة = هاتف + OTP مرة → جلسة مربوطة بالجهاز (D2). لا حقل `password`، لا «نسيت كلمة المرور»، لا عبء دعم. سيرو نفسه لا كلمة مرور حقيقية له (`password = hash(email)` وهمي). يطابق أوبر/كريم/inDrive — المعيار في هذه الأسواق. **التسجيل بالهاتف لا Google/Apple** (محظوران في بعض الدول).
|
||
- **تصحيح لسيرو**: رمزه من **3 خانات** (1000 احتمال — ضعيف). عندنا 4 (قابل للرفع عبر `OTP_LENGTH`)، وعدّاد 5 محاولات (D4) يغلق التخمين.
|
||
|
||
### D4 — حدّ الطلبات: كان مُعطَّلاً كلياً رغم أنه يبدو مفعَّلاً
|
||
`ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }])` كان مسجَّلاً في `app.module.ts` منذ البداية — لكن **بلا أي حارس يطبّقه**. لا `APP_GUARD`، ولا `@UseGuards(ThrottlerGuard)` في أي متحكّم. أي أن كل نقطة في الـAPI، بما فيها `verify-otp` و`payouts/*`، كانت **بلا أي حدّ طلبات إطلاقاً** — التسجيل وحده لا يفعل شيئاً في NestJS.
|
||
|
||
- ✅ `APP_GUARD → ThrottlerGuard` في `app.module.ts` — يُفعِّل الحدّ العام (120/دقيقة) على كل نقطة تلقائياً.
|
||
- ✅ **عدّاد محاولات لكل (مستأجر، رقم) في `AuthService.verifyOtp`** — الحماية الحقيقية ضد تخمين الرمز، لا الحدّ العام: رمز 4 خانات = 10000 احتمال، ومهاجم يدوّر عناوين IP يتجاوز أي حدّ بالـIP وحده. 5 محاولات ثم إبطال الرمز، بنفس نمط `payouts.service` (I4).
|
||
- ✅ حدود أضيق لكل نقطة على الأهداف عالية القيمة: `send-otp` (3/5د — كل إرسال يكلّف رسالة واتساب مدفوعة فعلياً عبر نبيه)، `verify-otp` (10/5د)، `payouts/request` (5/5د)، `payouts/confirm` (10/5د).
|
||
- ✅ `HealthController` مُستثنى (`@SkipThrottle()`) — مراقبة التشغيل بلا بيانات حساسة.
|
||
- 🟡 **قرار وعي بالمخاطرة**: فكّرت في تتبّع بالمستخدم المصادَق لا بالـIP وحده (مهم لتطبيق موبايل — عناوين NAT عند مشغّلي الجوّال تجمع آلاف المستخدمين خلف IP واحد). التنفيذ يحتاج حارساً مخصَّصاً بحقن يدوي دقيق (`InjectThrottlerOptions`/`InjectThrottlerStorage`)، وخطأ فيه **يمنع إقلاع التطبيق كاملاً** — ولا بيئة هنا لتشغيل `NestFactory.create()` والتحقق قبل الدفع (اختبارات jest تُنشئ الخدمات يدوياً، فلا تكشف أخطاء DI للحرّاس العالميين). رجّحت الأمان: تُرك بالتتبّع الافتراضي (IP)، والفكرة موثّقة هنا لتُنفَّذ حين يمكن اختبارها فعلياً على السيرفر قبل الدفع.
|
||
|
||
**مقارنة بسيرو** (`core/Auth/RateLimiter.php`): سيرو يتتبّع بـ`IP:userId` (النمط الذي أجّلناه أعلاه) وله حدود مسمّاة لكل نوع (login 5/د · otp 3/5د · register 3/ساعة · api 120/د)، **وأهم ميزة فيه: fallback بملف مؤقّت عند تعطّل Redis بدل تمرير كل الطلبات** (fail-closed). عندنا **مسار OTP fail-closed أصلاً**: تخزين الرمز والعدّاد عبر Redis مباشرةً، فتعطّل Redis يرمي ويُفشل الطلب لا يمرّره. لكن حارس الطلبات العام (ThrottlerGuard) يستعمل ذاكرة داخلية لكل نسخة — لا يشارك بين النسخ. **بند مؤجَّل**: تخزين Throttler على Redis + fallback fail-closed عند الحاجة للتوسّع الأفقي الفعلي.
|
||
|
||
---
|
||
|
||
## المجموعة E — كشف الاحتيال (من `driver_ride_scam`) — ✅ منفَّذة
|
||
|
||
| # | البند | الحالة |
|
||
|---|-------|--------|
|
||
| E1 | **تسجيل زر الاتصال** | ✅ `driver_called_passenger` + `last_call_at`/`last_call_by`/`call_count` على الرحلة نفسها (لا Redis فقط — حقيقة دائمة تفيد التحقيق بعد انتهاء الرحلة). يُسجَّل عند `call:offer` في `RealtimeGateway` — لحظة **بدء** الاتصال الفعلية، لا `answer`/`ice`/`end` (إشارات تفاوض لاحقة لنفس المكالمة). |
|
||
| E2 | **ربط الاتصال بالإلغاء** | ✅ `FraudService.recordCallThenCancel` — عدّاد **يومي منفصل** عن عدّاد الإلغاء العام (ذاك بالساعة ويعدّ كل إلغاء؛ هذا باليوم ويعدّ فقط الإلغاء **المسبوق باتصال خلال 30 دقيقة**). **3/يوم = إنذار (لا يمنع الإلغاء) — 6/يوم = حظر مؤقت**، طبق طلب المالك حرفياً. يُنادى من `TripsService.cancel` بقراءة خفيفة لعمود واحد قبل التحديث. |
|
||
|
||
---
|
||
|
||
## المجموعة F — الدردشة
|
||
| F1 | إشعار الرسالة يُرسل **فقط** إذا لم تكن صفحة الدردشة مفتوحة (المعالجة الأساسية في فلاتر؛ الباك إند يرسل دائماً ويترك القرار للعميل أو عبر presence). |
|
||
|
||
---
|
||
|
||
## المجموعة G — Redis خط أول والقاعدة خط احتياط — ✅ منفَّذة
|
||
> مراجعة المالك (2026-07-17، الجولة الثانية): «كل ما بدي أبعث notification أستعلم من القاعدة — هذا ثقيل. الريدز خط أول، القاعدة نقطة الاحتياط».
|
||
> القاعدة: البيانات **الساخنة والمتكررة** تُقرأ من Redis؛ القاعدة تُقرأ مرة واحدة عند البرود (cache miss) ثم تُكتب في Redis.
|
||
> الأساس: `common/cache/cache.service.ts` — كاش-جانبي مع مبدأ **فشل Redis لا يُسقط الطلب** (يُسجَّل ويُرجَع للقاعدة).
|
||
|
||
| # | البند | التفصيل | الحالة |
|
||
|---|-------|---------|--------|
|
||
| G1 | **لغة المستخدم في Redis** | فلاتر يفحص لغة الجهاز عند الفتح ويرفعها عبر `PATCH /users/me` → تُكتب في Redis مباشرة → الإشعار يقرأ من هناك. | ✅ `user:lang:{tenant}:{user}` (TTL يوم) + كتابة عند التحديث |
|
||
| G2 | **توكنات الأجهزة (FCM) في Redis** | كان استعلام `device_tokens` قبل كل إرسال. | ✅ `user:fcm:{tenant}:{user}`؛ يُبطَل عند `register` وعند اكتشاف توكن ميت |
|
||
| G3 | **البث الجماعي بلا استعلام لكل مستخدم** | — | ✅ التوكنات واللغة من الكاش، فالبث الجماعي صار بلا استعلامات قاعدة أصلاً |
|
||
| G4 | **`tenant` في Redis** | كاش `resolve` (slug↔UUID) + `config` مع إبطال عند الإنشاء/التعديل. | ✅ `tenant:{slugOrId}` (TTL ساعة) |
|
||
| G5 | **التعرفة و ride-types في Redis** | كاش مع **إبطال صريح** عند إنشاء تعرفة/نوع جديد. | ✅ `tariff:{t}:{city}:{class}` و `ridetypes:{t}` (TTL 15د) |
|
||
| G6 | **التقييم بتراكم يومي** | مجموع/عدد في Redis، والقاعدة تُكتب **مرة واحدة يومياً** لكل طرف (حجز الكتابة ذرّي بـLua). التأخير مقصود ليبعد الاحتكاك. **للطرفين معاً**. | ✅ + أُضيف `ratings.target_user_id` و`users.rating` — تقييم السائق للراكب كان يُخزَّن **بلا هدف فلا يُجمَّع أبداً** |
|
||
| — | **رحلة معلّقة للتقييم عند الفتح** | `GET /trips/rating/pending` — يرجع أحدث رحلة منتهية لم يقيّمها المستخدم (سائقاً أو راكباً) + العدد. التطبيق يناديها عند كل فتح ليفرض شاشة التقييم قبل رحلة جديدة (طلب المالك). | ✅ |
|
||
| — | **كاش الخرائط** | route/reverse/geocode في Redis بإحداثيات مقرَّبة 4 خانات (~11م). | ✅ TTL يوم. لا يُخزَّن الرجوع لخط مستقيم ولا استجابة فاشلة |
|
||
| G7 | **سعة Redis** | رصد مساحة أكبر أو Redis منفصل عند الحاجة (خصوصاً dispatch + المواقع). حالياً DB 3 مشترك — راجع docs/14. | ⏳ قرار تشغيلي — يُراجَع بعد المجموعة H |
|
||
|
||
**تصحيح لادّعاء سابق في هذا المستند:** كُتب أن `TenantsService.resolve` يعمل على كل request وأنه «أعلى نسبة قراءات في النظام». **هذا خطأ** — الـmiddleware يضع نص الهيدر في السياق فقط، و`tenant_id` يأتي جاهزاً من داخل الـJWT. `resolve` يُستدعى عند الدخول و3 كنترولرات فقط. كاشه مفيد لكن أثره أصغر بكثير مما ادّعيت. **الحمل الحقيقي الأثقل هو رفع موقع السائق** (استعلام + كتابة قاعدة كل نبضة) — وهو المجموعة H.
|
||
|
||
---
|
||
|
||
## المجموعة H — المواقع والتتبع (من `loction_server` في سيرو) — ✅ منفَّذة
|
||
> ملاحظة المالك: «لحد الآن مش شايف السائق أو الراكب يرفع موقعه، ولا جداول location».
|
||
> كان عندنا: `driver:location` عبر WebSocket → Redis GEO + **حفظ صف السائق في Postgres على كل نبضة** (`drivers.updateLocation` يعمل `repo.save`) — أثقل حتى من سيرو.
|
||
|
||
**ما يفعله سيرو (مقروء من الكود):**
|
||
- `driver_socket.php` — سوكيت مخصص للمواقع، **لا يلمس القاعدة إطلاقاً**؛ Redis فقط عبر **pipeline كل 500ms** (`REDIS_BATCH_INTERVAL`).
|
||
- عتبات لتقليل الكتابة: `MIN_MOVE_METERS=10` (GEOADD فقط عند تحرّك >10م)، `HMSET_SPEED_DELTA=1.0`، `HMSET_HEADING_DELTA=5`، `FORWARD_MIN_METERS=15` / `FORWARD_MAX_SECONDS=3` للبث للراكب.
|
||
- **فهرسان منفصلان**: `geo:drivers:available` و`geo:drivers:busy` (عندنا فهرس واحد لكل فئة خدمة، بلا تمييز مشغول/متاح).
|
||
- `driver:profile:{id}` hash في Redis (heading/speed/status) — المطابقة تقرأ منه بلا قاعدة.
|
||
- عروض الرحلة: `setex` للعرض + `sadd` لمجموعة المعروض عليهم + `expire` — **نفس نمطنا في A5** ✅.
|
||
|
||
| # | البند | التفصيل | الحالة |
|
||
|---|-------|---------|--------|
|
||
| H1 | **إيقاف كتابة الموقع على القاعدة لكل نبضة** | `drivers.updateLocation` كان يكتب Postgres كل ثانية/ثلاث لكل سائق متصل. | ✅ Redis فقط. الكتابة الدائمة صارت من الـworker |
|
||
| H2 | **عتبات + batching** | عتبات سيرو: 10م حركة · 1.0 سرعة · 5° اتجاه. سائق واقف = `EXPIRE` فقط (بلا كتابة ولا نقطة مسار ولا بثّ). | ✅ + البثّ للراكب صار عند الحركة ذات الدلالة فقط |
|
||
| H3 | **لقطة «آخر موقع» لكل سائق** | صف واحد لكل سائق يكتبه الـworker دورياً. **قرار: لم نُنشئ جدولاً منفصلاً** — `drivers` أصلاً صف واحد لكل سائق؛ أُضيفت `heading`/`speed`/`loc_status`/`loc_updated_at` بجانب `last_lat/last_lng`. | ✅ |
|
||
| H4 | **`driver_tracks` (المسار)** | نقاط تاريخية للتتبع/النزاعات — تتراكم في قائمة Redis ويُدرجها الـworker **بعبارة واحدة**. | ✅ جدول `tripz_driver_tracks` |
|
||
| H5 | **فهرس available/busy** | `geo:drivers:{tenant}:{class}:{available\|busy}` — البحث يمسح المتاحين فقط. السائق يصير busy عند القبول ويعود available عند الإنهاء/الإلغاء. | ✅ |
|
||
| H8 | **ربط المواقع بالمطابقة** | المطابقة والاحتيال و«الرحلات المتاحة» صاروا يقرؤون الموقع الحيّ من Redis. | ✅ |
|
||
| H6 | **`driver_behavior`** | max_speed · avg_speed · hard_brakes · total_distance · behavior_score لكل رحلة. | ⏳ **مؤجَّل** — تحليلات فوق المسار، تُبنى من `driver_tracks` لاحقاً |
|
||
| H7 | **`driver_daily_work` / `driver_daily_summary`** | ساعات عمل السائق (total_seconds باليوم + last_point_at + last_status). | ⏳ **مؤجَّل** |
|
||
| — | **Geofence** | مؤجَّل بقرار المالك («خليها لوقتها») — موجود في سيرو (`get_location_area_links`, `LocationIntelligenceEngine`). | ⏳ |
|
||
|
||
**الـworker صار حقيقياً**: كان هيكلاً فارغاً (`setInterval` بلا عمل). الآن `NestFactory.createApplicationContext(WorkerModule)` — قاعدة + Redis + المواقع، يفرّغ كل 5s (`LOC_FLUSH_INTERVAL_MS`)، بقفل يمنع تراكب الدورات، وتفريغة أخيرة عند SIGTERM حتى لا تضيع اللقطات عند النشر.
|
||
|
||
**الأثر**: سائق متصل كان يكلّف استعلاماً + كتابة قاعدة لكل نبضة (كل 1–3 ثوانٍ). الآن: نبضة السائق الواقف = أمر `EXPIRE` واحد، والمتحرّك = كتابة Redis + كتابة قاعدة واحدة كل 5 ثوانٍ **مهما بلغ عدد نبضاته**.
|
||
|
||
---
|
||
|
||
## المجموعة I — المدفوعات والمحفظة (من `payment_server` في سيرو) 🔴 أمني
|
||
> طلب المالك: تدقيق أمني على المدفوعات، خصوصاً **الـpayout**: OTP عبر نبيه + بصمة (وجه/إصبع) في فلاتر + HMAC.
|
||
|
||
**بنية سيرو (مقروءة من `WalletDB.sql` + `sms_webhook/`):**
|
||
- **جدول لكل طريقة دفع** (كما قال المالك): `cliq_invoices` · `ecash_transactions` (+`_driver`) · `invoices_shamcash` (+`_passenger`) · `mtn_invoices` · `invoices_sms` (+`_passenger`) · `kazan`.
|
||
- **محافظ منفصلة**: `driverWallet` · `passengerWallet` · `siroWallet` (محفظة الشركة) — نمط **دفتر قيود append-only**، الرصيد = `SUM(amount)`.
|
||
- **نمط ذكي جداً**: `raw_sms_log` + `process_with_gemini.php` — رسالة SMS من مزوّد الدفع تُرفع خاماً، **Gemini يقرأها** ويستخرج المبلغ/المرجع، ثم `finalize_wallet_payment`. يحلّ غياب الـAPI الرسمي في سوريا.
|
||
- `payment_tokens` (+`_passenger`) للتتبع/عدم التكرار · `admin_audit_log` · `paymentsLogSyria(Driver)`.
|
||
|
||
**🔴 ثغرات وجدتها في كود سيرو — لا تُنقل كما هي:**
|
||
| الثغرة | التفصيل |
|
||
|--------|---------|
|
||
| **IDOR في `request_payout.php`** | `driverId` و`phone` يُؤخذان من **الطلب** لا من الـJWT → سائق يطلب سحب رصيد سائق آخر إلى هاتفه. **يجب** اشتقاق الهوية من التوكن حصراً. |
|
||
| **لا خصم عند الطلب** | الطلب يفحص الرصيد ثم يُدرج سجلاً فقط بلا حجز → **طلبات متعددة متزامنة تمرّ كلها** (double-spend). |
|
||
| **عمولة متناقضة** | `request_payout` يفحص `balance >= amount + 3500` (العمولة فوق المبلغ)، و`finalize_payout` يحسب `netAmount = amount - 3500` ويخصم الصافي فقط (العمولة داخله) → **تسريب مال**. والرقم 3500 مكرر حرفياً في الملفين. |
|
||
| **`finalizePayout` بلا معاملة** | 5 عمليات كتابة بلا `beginTransaction`/`rollBack` → فشل في المنتصف = خصم بلا تسجيل عمولة (حالة نصفية). |
|
||
| **`UPDATE payments SET isGiven=TRUE WHERE driverID=… AND isGiven=FALSE`** | يعلّم **كل** الدفعات المعلّقة كمدفوعة بغضّ النظر عن مبلغ السحب. |
|
||
| **`driverWallet.amount` = `varchar(10)`** | المال مخزَّن كنص (وحساب `SUM` على varchar). |
|
||
| **لا OTP على الـpayout** | `phone_verification` موجود لكنه مستخدم في **التسجيل/الدخول فقط** — لا شيء يحمي السحب. (ملاحظة المالك صحيحة.) |
|
||
| **جدولان متداخلان** | `payout_requests` و`driver_withdrawal_requests` لنفس المفهوم. |
|
||
|
||
**🔴 ثغرة في كودنا نحن (`wallet.service.ts`):**
|
||
`credit`/`debit` تعمل read-modify-write على عمود `balance` بلا قفل ولا معاملة → **سباق حقيقي**: خصمان متزامنان يقرآن نفس الرصيد ويكتبان فوق بعض = مال مفقود/مخلوق. سيرو هنا **أفضل منّا** (دفتر قيود + `FOR UPDATE`).
|
||
|
||
| # | البند | التفصيل |
|
||
|---|-------|---------|
|
||
| I1 | ✅ **إصلاح سباق المحفظة** | **منفَّذ ومُثبَت على السيرفر** (commit 9d6b752): `UPDATE … SET balance = balance ± :delta WHERE tenant_id … AND balance >= :amount RETURNING *` — عبارة واحدة ذرّية، والقيد+الرصيد في معاملة واحدة. أُضيف `wallet_txns.balance_after` وقيد `CHECK (balance >= 0)` كشبكة أمان. إنشاء المحفظة عبر `ON CONFLICT DO NOTHING`.<br>**نتيجة `wallet-race-test.mjs 100 5` على Postgres حقيقي:** 100 خصم متزامن → نجح 50 بالضبط، رُفض 50، الرصيد 250→0، المخصوم = 250 (لا مال ضائع ولا مخلوق)، الزمن 1492ms. ✅ |
|
||
| # | البند | الحالة |
|
||
|---|-------|--------|
|
||
| I4 | **OTP على الـpayout عبر نبيه** | ✅ تدفّق خطوتين: `POST /payouts/request` يرسل رمزاً **بلا حركة مال**، و`POST /payouts/:id/confirm` يتحقق ثم يحجز ذرّياً. حدّ 5 محاولات، الرمز يُستهلك مرة واحدة، و`complete` يرفض طلباً بلا `otp_verified_at`. **قاعدة: لا يتحرك مال قبل إثبات الهوية.** |
|
||
| I5 | **بصمة (وجه/إصبع)** | ✅ الجزء الخلفي: `biometric_method`/`biometric_at` + `device_id` + `request_ip` تُسجَّل مع السحب. **أثرٌ للتحقيق لا مصادقة** — العميل يستطيع ادّعاءها؛ المصادقة الحقيقية JWT + رمز واتساب. الإثبات الحيّ نفسه في فلاتر. |
|
||
| I6 | **HMAC على العمليات المالية** | 🟡 **مبنيّ ومطفأ** (`PAYMENTS_REQUIRE_SIGNATURE=false`) — يحتاج فلاتر أن يوقّع أولاً، وتفعيله قبل ذلك يقطع كل سحب. **مفتاح لكل جلسة** يُصدره الدخول (`signing_key`) لا سرّ ثابت في التطبيق (الثابت يُستخرج بالهندسة العكسية فيصير التوقيع مسرحية). يوقّع `timestamp.METHOD.path.body` بنافذة 5 دقائق. |
|
||
| I7 | **سجل تدقيق مالي** | ✅ `tripz_audit_log` (append-only): من · ماذا · متى · من أي IP وجهاز. يغطّي `payout.request/confirm/complete/fail` و`otp_failed`/`otp_blocked` و`signature.rejected`. **لا يرمي أبداً** — فشل التدقيق لا يُسقط عمليةً نجحت. |
|
||
| I2 | **محفظة المنصة** | ⏳ **أُلغيت الحاجة إليها عملياً**: بعد نموذج الرصيد التشغيلي (docs/18) لم تعد المنصة تقبض عمولة من كل رحلة — إيرادها = **الشحن**، و`credit_txns` هو دفتر ذلك الإيراد فعلاً (`SUM` الشحن لكل مستأجر). محفظة ثانية = مسك دفتر مزدوج يحتاج مطابقة. البند الباقي = **تقرير إيراد** لا محفظة. |
|
||
| I3 | **جدول لكل طريقة دفع** | ⏳ لم يُنفَّذ — إعادة هيكلة مخطط كاملة (`tripz_pay_payments` عام حالياً). يُنفَّذ مع أول ربط بوابة حقيقية، لا قبله. |
|
||
| I8 | **webhook SMS + Gemini** | ⏳ لم يُنفَّذ — ميزة كاملة (`raw_sms_log` + استخراج + تسوية). تُبنى عند دخول السوق السوري فعلياً. |
|
||
|
||
### 🔴 TLS — أخطر ثغرة مالية كانت قائمة ✅ منجَز ومُثبَت. **المستند: [20-tls](20-tls.md)**
|
||
**مُثبَت على السيرفر (2026-07-17)**: `https://tripz-api.intaleqapp.com/api/health` → 200 عبر nginx+شهادة. `http://194.163.173.157:4010/api/health` → Connection refused. المنفذ الخام لم يعد قابلاً للوصول من الإنترنت إطلاقاً.
|
||
الـAPI كان على `http` بلا TLS: من يلتقط الشبكة يسرق توكن أي سائق ويسحب أرباحه، ولا OTP ولا HMAC ولا تدقيق يمنع ذلك.
|
||
**النطاق المعتمد: `tripz-api.intaleqapp.com`.** الجزء البرمجي ✅ (المنافذ + trust proxy)؛ الباقي خطوات سيرفر يدوية في docs/20.
|
||
|
||
وأثناء العمل ظهرت ثغرتان تُبطلان TLS من أصله لو تُركتا:
|
||
- **`4010` كان مكشوفاً على `0.0.0.0`** → صار `127.0.0.1`. بلا هذا يبقى `http://IP:4010` مفتوحاً فيتجاوز TLS كلياً والشهادة زينة.
|
||
- **Postgres كان مكشوفاً على `0.0.0.0:55432`** — أي القاعدة على الإنترنت مباشرة → صار `127.0.0.1` (الوصول من الماك عبر نفق SSH).
|
||
- **`trust proxy = 1`**: خلف Nginx كان `req.ip` سيصير `127.0.0.1` للجميع فيمتلئ سجل التدقيق المالي (I7) بعنوان البروكسي بدل السائق — أثرٌ بلا قيمة عند النزاع. والقفزة الواحدة مقصودة: الثقة المفتوحة تسمح بتزوير `X-Forwarded-For`.
|
||
|
||
---
|
||
|
||
## قرار: هل يخاطب فلاتر خرائط انطلق مباشرة؟
|
||
**القرار المتّخذ (2026-07-17): تقسيم حسب نوع النداء — لا «كله مباشر» ولا «كله عبر السيرفر».**
|
||
|
||
| النوع | المسار | السبب |
|
||
|------|--------|-------|
|
||
| **البلاطات (tiles)** | فلاتر → انطلق **مباشرة** | حجم كبير ومتكرر، لا يحمل أسراراً، ويُخزَّن مؤقتاً على الجهاز. تمريره عبر سيرفرنا = تضخيم عرض النطاق بلا فائدة. |
|
||
| **geocode / reverse / route / places** | فلاتر → **سيرفرنا** → انطلق | يحمي مفتاح API (مفتاح داخل التطبيق = مسروق)، يتيح كاش Redis، حصص لكل مستأجر، وتبديل المزوّد بلا تحديث التطبيق. |
|
||
|
||
**السرعة ليست مقايضة هنا — بالعكس:** اختبار التحميل عندنا أظهر أن p50 قفز إلى **2.9 ثانية** والسبب المهيمن هو نداء HTTP الخارجي إلى انطلق. كاش النتائج في Redis (نفس العنوان يُطلب آلاف المرات) يجعل المسار عبر سيرفرنا **أسرع** من النداء المباشر، لا أبطأ. الإضافة الصافية للسيرفر ~10–30ms مقابل توفير ~2.9s على كل إصابة كاش.
|
||
→ **البند المطلوب: كاش Redis لنتائج geocode/route/places** (يُضاف للمجموعة G).
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## المجموعة J — الرصيد التشغيلي وعمولة السائق — ✅ منفَّذة (عدا J5)
|
||
> **المستند الكامل: [18-driver-credit-commission](18-driver-credit-commission.md).**
|
||
> السائق يشحن رصيداً تشغيلياً سلفاً · الراكب يدفع له الأجرة كاملة · العمولة تُخصم من الرصيد لا من الأجرة.
|
||
> نموذج معتمد عالمياً في أسواق الكاش (inDrive · Yango · سيرو) — وليس نموذج أوبر.
|
||
|
||
| # | البند | الحالة |
|
||
|---|-------|--------|
|
||
| J1 | `driver_credits` + `credit_txns` — خصم ذرّي (نمط I1) لكن **بلا شرط رصيد**: الدين مسموح عمداً، ولا قيد `>= 0` على الجدول | ✅ |
|
||
| J2 | خصم العمولة عند `completed` من الرصيد التشغيلي — كاشاً كانت أو محفظة (قاعدة واحدة) | ✅ |
|
||
| J3 | **تصحيح B6**: `price_for_driver = price_for_passenger`؛ `TariffEngine.split` → `commission` (بلا سقف بالأجرة) | ✅ |
|
||
| J4 | الحجب عند تجاوز `credit_floor` فقط — عند `setOnline` و`accept`، **لا** أثناء رحلة جارية | ✅ |
|
||
| J6 | مكافأة التسجيل عند **الاعتماد** لا التقديم: 3 JOD · 300 SYP · 300 EGP. حارسان: فحص تطبيقي + فهرس فريد جزئي | ✅ |
|
||
| J7 | `credit_floor` لكل عملة: −5 JOD · −500 SYP · −500 EGP | ✅ |
|
||
| J8 | `GET /credit` (الرصيد · الأرضية · مَدين؟ · محجوب؟) و`GET /credit/transactions` | ✅ |
|
||
| — | **تصحيح B3**: مشوار الوصول = تعويض إلغاء بعد انقضاء الانتظار المجاني — لا بند في كل أجرة | ✅ |
|
||
| J5 | باقات شحن لكل عملة (`promo_bonus` جاهز كنوع قيد؛ الباقات نفسها لم تُبنَ) | ⏳ |
|
||
|
||
**الأرقام ثوابت في `driver-credit.service.ts` مؤقتاً** (المكافأة والأرضية) — تُنقل لإعداد المستأجر عند بناء J5.
|
||
|
||
> **أثره على I2**: محفظة المنصة لم تعد تستقبل عمولة من كل رحلة — بل تستقبل **مدفوعات الشحن**. هذا يبسّط I2 كثيراً.
|
||
|
||
---
|
||
|
||
## المجموعة K — الاستحقاقات ومنع تفعيل الميزات بلا اشتراك — ✅ منفَّذة
|
||
> **المستند الكامل: [19-entitlements-licensing](19-entitlements-licensing.md).**
|
||
> القاعدة: **علم الميزة في التطبيق قرار عرض لا حدّ أمني.** الحدّ الحقيقي حارس على السيرفر.
|
||
|
||
| # | البند | الحالة |
|
||
|---|-------|--------|
|
||
| K1 | `FeatureGuard` + `@RequiresFeature()` — الاستحقاقات من Redis والقاعدة احتياط. الوحدة **عالمية** عمداً: إجبار كل وحدة على استيرادها يعني نقطة منسيّة يوماً ما، والمنسيّة ميزةٌ مجانية للجميع | ✅ |
|
||
| K2 | `common/entitlements/features.ts` — الكتالوج + افتراضات كل باقة. **الافتراض هو المنع**: قائمة بيضاء، وميزة جديدة تبقى محجوبة حتى تُمنح | ✅ |
|
||
| K3 | `GET /admin/features` · `GET /admin/tenants/:id/entitlements` · `PATCH /admin/tenants/:id/subscription` (يدمج `features` لا يستبدلها، ويُبطل الكاش فوراً) | ✅ |
|
||
| K4 | `drivers_max` يُفرض عند `drivers.apply` — عند الإنشاء فقط، فسائق قائم لا يُطرد بتغيير باقة | ✅ (`cities_max` معرَّف بلا نقطة إنفاذ بعد) |
|
||
| K5 | `GET /tenant/config` يرجع **الاستحقاقات المحسوبة** لا `features` الخام (الخام تجاوزات فقط، فكانت ستُظهر ميزات الباقة مطفأة) — موثّقة كعرض لا حماية | ✅ |
|
||
| K6 | `feature.guard.spec` + `features.spec` — منها اختبار أن الترويسة لا تزوّر المستأجر، وأن الحارس يقرأ التوكن وحده | ✅ |
|
||
| K7 | السيادة = كل الميزات بلا حدود، قراراً مقصوداً (لا وهم حماية تقنية) | ✅ |
|
||
|
||
**نقاط محروسة الآن**: `dispatch` · `chat` · `payments/charge`. المدفوعات محروسة **لكل نقطة** لا على مستوى الصنف — الـwebhook يناديه المزوّد بلا JWT فكان حارس الصنف سيمنعه.
|
||
|
||
**تغيير مقصود على الـseed**: المستأجر التجريبي `siro` صار `sovereign` بدل `brand`. سببان: يطابق قرار المالك («سيرو أول مشترك، له الباقة الكاملة»)، ويمنع حجب `dispatch` وحدّ `drivers_max: 500` من إسقاط سكربتات التحقق (كل تشغيلة تحميل تُنشئ عشرات السائقين وهم يتراكمون).
|
||
|
||
**حارس السوبر-أدمن ✅**: `PlatformGuard` (`x-platform-secret`, مقارنة ثابتة الزمن) يحرس كل `admin/tenants*` و`admin/features` و`platform/*`. **سرّ غير مضبوط = منع الجميع** لا فتح الباب. حرجٌ لأنه بدونه يرقّي أدمن أي مستأجر اشتراكه بنفسه — أي أن K كلها كانت زينة. `admin/users/:id/role` تبقى لأدمن المستأجر (نطاقها من التوكن) لأنها ليست نقطة منصة.
|
||
|
||
**سياسة الدين لكل مستأجر ✅** (قرار المالك): `tenants.settings.credit` — `debt_allowance` و`signup_bonus` لكل عملة، يضبطهما أدمن المستأجر عبر `PATCH /credit/policy`. الافتراضات 2 JOD · 200 SYP · 200 EGP، و**0 خيار صريح = لا دين**. `settings` منفصلة عن `features` عمداً: تلك «ماذا اشتريت» (سوبر-أدمن)، وهذه «كيف تشتغل» (المستأجر). هجرة `TenantSettings`.
|
||
|
||
**ما تبقّى**: نقطة إنفاذ لـ`cities_max` (لا مفهوم «مدينة» مُدار بعد)، ونموذج مستخدم منصة حقيقي بدل السرّ المشترك.
|
||
|
||
---
|
||
|
||
## المجموعة L — الإحالات والمكافآت والكوبونات — ✅ منفَّذة
|
||
> قرار المالك 2026-07-18. **قانونان ثابتان يحكمان المجموعة كلها:**
|
||
> 1. **محفظة الراكب لا تُسحب أبداً** — فمكافأة الراكب **كوبون خصم** لا رصيد محفظة. إنزالها في المحفظة كان يخلق التزاماً نقدياً قابلاً للسحب مقابل حملة تسويقية.
|
||
> 2. **كل خصم مسقوف بعملته** — 25 جنيهاً في مصر · 25 ليرة سورية · 0.25 دينار (نفس نسبة `credit-policy`). السقف **حدّ أعلى مطلق** لا افتراض.
|
||
|
||
| # | البند | الحالة |
|
||
|---|-------|--------|
|
||
| L1 | `referral_codes` (كود شخصي ثابت) + `referrals` بحالات `pending → qualified → paid` | ✅ |
|
||
| L2 | نوع قيد `referral_bonus` في `credit_txns` + `grantReferralBonus` بحارسَي J6 (فحص تطبيقي + فهرس فريد جزئي على `ref`) | ✅ |
|
||
| L3 | محرّك الكوبونات: `coupons` + `coupon_redemptions`. يخصم من `price_for_passenger` وحده — `price_for_driver` يبقى كاملاً (docs/18 §52) | ✅ |
|
||
| L4 | **شرط الاستحقاق**: بعد أول رحلة مكتملة للمُحال لا عند التسجيل. `qualifying_trips` لا ينزل تحت 1 ولو ضبطه الأدمن صفراً | ✅ |
|
||
| L5 | `RewardsSweeper` — نمط `ScheduledTripsSweeper` (setInterval + قفل `running`). **شبكة أمان لا مسار أساسي**: الصرف يقع لحظة التأهّل | ✅ |
|
||
| L6 | `tenants.settings.rewards` بنفس بنية `credit-policy` — سقف الخصم ومكافآت الطرفين لكل عملة | ✅ |
|
||
| L7 | ربط `fraud.service` — كشف حلقات الإحالة والأجهزة المكرّرة | ⏳ |
|
||
| L8 | باقات الشحن (J5) فوق نفس المحرّك | ⏳ |
|
||
|
||
**اتجاهات الإحالة الأربعة ليست أربعة مسارات**: شكل المكافأة يتبع **دور المستفيد** لا اتجاه الإحالة — سائق ← رصيد تشغيلي، راكب ← كوبون. والدور يُحسم **وقت الصرف** من وجود صفّ سائق لا من الدور المخزَّن وقت التسجيل، فمن سجّل بكود دعوة ثم تقدّم سائقاً يستحقّ رصيداً لا كوبوناً.
|
||
|
||
**حرّاس الاحتيال (على القاعدة لا بالفحص التطبيقي وحده)**:
|
||
- فريد على `(tenant_id, referee_user_id)` — الشخص يُحال **مرة واحدة** في عمره. بدونه تُعاد نفس الضحية تحت عشرة داعين.
|
||
- `CHECK (referrer <> referee)` — إحالة النفس.
|
||
- فريد جزئي على `credit_txns(tenant_id, ref) WHERE type='referral_bonus'` وعلى `coupons(tenant_id, ref)` — الماسح والصرف الفوري قد يلتقيان على إحالة واحدة.
|
||
- فريد على `coupon_redemptions(tenant_id, trip_id)` — كوبون واحد لكل رحلة.
|
||
|
||
**درس مثبَّت باختبار**: المحاولة الأولى حسبت عتبة التأهّل داخل SQL (`CASE WHEN qualifying_trips + 1 >= n`)، فجُمع العمود **نصّاً** (`'0' + 1 = '01'`) ولم تتحقق الشرطية أبداً — كل إحالة تبقى معلّقة بلا مكافأة، وهو فشل صامت لا يُكتشف إلا بشكوى مستخدم. الحساب صار في TypeScript عبر `RETURNING`، والتزامن محفوظ بشرط الحالة في كلتا العبارتين.
|
||
|
||
**العمولة تُحتسب قبل خصم الكوبون** عمداً: الكوبون تنازل تسويقي من المنصة، فاحتسابها بعده كان يجعل المنصة تدفع الخصم مرتين — مرة للراكب ومرة بعمولة أنقص من السائق.
|
||
|
||
---
|
||
|
||
## المجموعة M — أساس التسعير: تعرفة الانطلاق والمسافة الحقيقية — ✅ منفَّذة
|
||
> قرار المالك 2026-07-19. **الحدّ الأدنى للرحلة: 1.10 دينار · 20 جنيهاً · 150 ليرة** — وهذه المرساة التي تُشتقّ منها كل الأرقام الأخرى.
|
||
|
||
| # | البند | الحالة |
|
||
|---|-------|--------|
|
||
| M1 | `default-tariffs.ts` — مصدر حقيقة واحد يستهلكه `SeedService` ومولّد الـSQL معاً | ✅ |
|
||
| M2 | `city = 'default'` بدل اسم البلد؛ البلد من `country_pack` للمستأجر | ✅ |
|
||
| M3 | نوافذ التعرفة **بالتوقيت المحلي** (`timezone` في التعريف) عبر `Intl` — لا UTC ولا توقيت الخادم | ✅ |
|
||
| M4 | فتحة العدّاد (`flag`) حقيقية في كل نافذة، ورسم حجز (`booking_fee`) | ✅ |
|
||
| M5 | `fixed_quote` منفَّذ فعلاً — لا مسافة ولا زمن ولا حدّ أدنى؛ الانتظار وحده يُضاف | ✅ |
|
||
| M6 | الفئات مشتقّة بمعاملات: أوفر < اقتصادي < كهربائي < ليدي < مريح < فان < VIP | ✅ |
|
||
| M7 | `TripDistanceService` — المسافة من نبضات GPS مع تنقية الضجيج والقفزات | ✅ |
|
||
| M8 | إعادة التسعير عند الإنهاء على المسافة المقطوعة فعلاً، بسقف `routed × 1.25` | ✅ |
|
||
| M9 | زرع التعرفة **لكل فئة خدمة** لا الاقتصادي وحده | ✅ |
|
||
| M10 | سماحية المسافة لكل مستأجر + ربط الانحراف بـ`fraud.service` | ⏳ |
|
||
|
||
**الأعطال التي أُصلحت (كلها كانت فشلاً صامتاً)**:
|
||
- **رحلة مجانية**: السكربت القديم كتب `city='jordan'`، والكود يبحث بـ`'default'` → لا تعرفة → `quotedFare = null` → `settleFare` تأخذ `quoted_fare ?? 0` → **أجرة صفر وعمولة صفر**، بسطر تحذير واحد في اللوج.
|
||
- **ذروة لا تُحصَّل أبداً**: `pickWindow` قرأ `getUTCHours()`، فذروة 16:00–20:00 تقع 19:00–23:00 في عمّان.
|
||
- **الفان أرخص من الاقتصادي** في مصر وسوريا (22 مقابل 29.5 جنيهاً لنفس الرحلة) — أي أن الخيار العقلاني لكل راكب هو أغلى فئة على المشغّل.
|
||
- **حدّ أدنى ثلث الحقيقي**: `perKm × 2` = 0.368 ديناراً بدل 1.10.
|
||
- **`fixed_quote` لم يكن ثابتاً**: كان يسقط للفرع المتري فيُحسب بالعدّاد رغم اسمه.
|
||
- **السكربت لم يعمل أصلاً**: `require` في ملف ESM؛ ولا وجود لـ`seed-tariffs.sql` إطلاقاً.
|
||
- **تشغيلتان = تعرفتان فعّالتان** بنفس النسخة، و`getActive` ترتّب `version DESC` فالاختيار بينهما غير محدَّد.
|
||
|
||
**سياسة المسافة (M7/M8)**: يُحاسَب **الأطول** من (المقاس، المقدَّر) بسقف `routed × 1.25`. التحويلة الحقيقية يقبضها السائق، والجولة المخترَعة تُقصّ وتُعلَّم للمراجعة لا تُخصم صامتة. والطريق الأقصر يُحاسَب بالأقصر — الراكب لا يدفع تقديراً لم يُقطع. نبضات أقل من 5 = رجوع للسعر المقفول: رقم مخترَع من الضجيج أسوأ من تقدير معقول.
|
||
|
||
**تصحيح على المجموعة L**: سقوف الكوبونات كانت مبنية على نسبة `credit-policy` القديمة (1:100:100)، وهي لا تطابق مرساة المالك (1.10 = 20 = 150، أي 1 دينار ≈ 18.2 جنيهاً ≈ 136 ليرة). السقف 25 جنيهاً صار يقابل **1.375 ديناراً و190 ليرة** بدل 0.25 و25 — النسخة الأولى كانت تعطي سوريا سُدس العرض.
|
||
|
||
---
|
||
|
||
### M — التحديث الثاني: الأرقام من جدول `kazan` الحيّ (2026-07-19) — ✅ مُثبَتة على السيرفر
|
||
|
||
بعد الحصول على جدول `kazan` من قاعدة سيرو، أُعيد بناء الأرقام منه لا من تقدير. العمولة (11٪ · 12٪ · 14٪) طابقت ما عندنا حرفياً؛ وثلاثة تصحيحات لزمت:
|
||
|
||
| الاكتشاف | الدليل في البيانات |
|
||
|---|---|
|
||
| **محورا الأردن مقلوبان** | أعمدة الفئات تحمل سعر الدقيقة (0.043) وأعمدة النوافذ سعر الكيلومتر (0.184) — عكس مصر وسوريا. قراءتها كما هي = رحلة 27 كم بأقلّ من ديناريَن |
|
||
| **نِسَب مصر وسوريا معطوبة** | الكهربائي 0.23 والفان 0.25–0.40 من الاقتصادي — أي فان أرخص من اقتصادي. نِسَب الأردن وحدها متماسكة (0.86 → 1.51) فاعتُمدت للثلاثة |
|
||
| **لا فتحة عدّاد** | `startPrice = 0` في كل الصفوف. قرار المالك: **الحدّ الأدنى هو الآلية** («ما في رحلة أقل من 20 جنيهاً») لا رسم يُضاف فوق كل أجرة |
|
||
|
||
**البنية النهائية** (وهي ما سيكتب فيه بوت استخبارات الأسعار): سعر الكيلومتر يتغيّر **بالفئة** فقط — رقم يعرفه السوق فلا يتذبذب بالساعة؛ وسعر الدقيقة يتغيّر **بالنافذة** فقط. `fuelPrice` محفوظ كمدخل للبوت. بنية سيرو تُمثَّل عندنا بلا خسارة، والعكس غير صحيح (لا منطقة زمنية ولا إصدارات ولا سماحية مسافة عنده).
|
||
|
||
**تسريبان أغلقهما الاختبار الحيّ** (بموافقة المالك):
|
||
- **فئة مجهولة = رحلة مجانية**: `service_class` يأتي من الطلب بلا تحقّق، والاستثناء كان يُبتلع فتُنشأ رحلة بـ`quoted_fare = null` ثم تُسوّى بصفر. صار الطلب يُرفض بـ400.
|
||
- **`fixed` أُخرجت من الزرع**: سعر ثابت بلا جدول خطوط = رقم واحد لكل الوجهات — أظهر الاختبار رحلة 55.8 كم بـ2.5 ديناراً مقابل 23.2 للاقتصادي.
|
||
|
||
**الإثبات على السيرفر (2026-07-19)**: 72 طلباً حقيقياً (8 فئات × 3 مسافات × 3 بلدان) بمسافات فعلية من انطلق (2.973 / 27.401 / 55.765 كم)، كلها رجعت سعراً مطابقاً للحساب اليدوي، و3 رفضات لـ`fixed` مؤكَّدة. السكربت: `scripts/verify-and-test.sh` (يفحص البيئة → يقلع → ينتظر `/health` → **يبطل كاش التعرفة** → يختبر).
|
||
|
||
**درسان تشغيليان مثبَّتان في السكربت لا في الذاكرة**:
|
||
1. **كاش Redis ينجو من إعادة تشغيل الـapi** (حاوية منفصلة). تطبيق `tariffs.sql` مباشرةً يتجاوز إبطال التطبيق، فخدم النظام أسعاراً قديمة عبر **دورتَي نشر** وهو يعلن النجاح.
|
||
2. **`OTP_DEV_MODE=true` مع `NODE_ENV=production` يمنع الإقلاع** (`process.exit(1)` لا تحذير)، فيظهر العطل لاحقاً كـ`EAI_AGAIN` داخل الاختبار — أي في المكان الخطأ تماماً.
|
||
|
||
**مؤجَّل بقرار**: جدول الخطوط الثابتة (أصل → وجهة → سعر) لتفعيل فئة `fixed`.
|
||
|
||
---
|
||
|
||
## المجموعة N — مراجعة دفعة النموذج الآخر (غير مرفوعة، 2026-07-19) 🔴 قواطع نشر
|
||
> دفعة كبيرة **غير committed** بناها نموذج آخر: cron موحّد على BullMQ (O5) · تسعير ديناميكي (L4) · شرائح سائقين (M2 في docs/22) · كتالوج ميزات (N6/N7) · محرّك تسويق (O3) · بوتات (O4) · بوابة transit (O2) · خريطة حرارية (O1) · كنس searching (R1) · تسجيل صوتي (R2) · تقارير مستأجر (P4) · بوابتا MTN/SyriaTel (P1). راجعتُ كل ملف سطراً سطراً بدل قبول الادّعاء. **لا تُرفع قبل حل N1–N3.**
|
||
|
||
| # | البند | التفصيل | الحالة |
|
||
|---|-------|---------|--------|
|
||
| N1 | **لا migrations لأي إضافة** | 7 جداول جديدة بلا migration (`job_executions` · `surge_states` · `bot_tasks` · `feature_catalog` · `ride_type_catalog` · `campaigns` · `trip_audios`) + 4 أعمدة جديدة على `tripz_drivers` القائم (`tier`/`tier_score`/`total_trips`/`acceptance_rate` في `drivers/entities/driver.entity.ts`). الأخطر: أعمدة `drivers` الجديدة تكسر **كل** استعلام سائق قائم بعد النشر، لا الميزات الجديدة فقط. | 🔴 |
|
||
| N2 | **خطأ TypeORM يمنع الإقلاع** | `tariff/entities/surge-state.entity.ts` — `manual_note: string \| null` بلا `type:` صريح في `@Column` → `DataTypeNotSupportedError` (نفس الفخ الموثّق: union type بلا `type:` صريح). | 🔴 |
|
||
| N3 | **BullMQ + ioredis keyPrefix ممنوع** | `common/cron/bull.module.ts` و`cron-worker.service.ts` يمرّران `keyPrefix` على اتصال BullMQ — المكتبة تمنع هذا صراحة (العزل مضمون أصلاً بـ`db: 3` + خيار `prefix: 'tripz_'` الموجود بجانبه). حذف سطر `keyPrefix` من الاتصالَين يكفي. | 🔴 |
|
||
| N4 | **Surge L4 بلا مغذّي** | `SurgeService.incrementDemand` بلا أي مستدعٍ في كل الكود → `demand_count` يبقى صفراً للأبد، فالمضاعف يبقى 1.0 دائماً رغم كرون يعمل كل 3 دقائق بلا فائدة. | 🔴 وظيفياً |
|
||
| N5 | **Tiers تكسر Redis-first** | `total_trips`/`acceptance_rate` لا يحدّثهما أي كود بعد رحلة. **والأخطر**: `matching.service.ts` صار يقرأ Postgres داخل `findNearby` — كسر صريح لقاعدة Redis أولاً في أسخن مسار عندنا (المجموعة H). الفرز بالشريحة أولاً يعني سائقاً بعيداً 9كم يسبق آخر على 200م. **الشرائح مؤجَّلة بقرار سابق — بُنيت دون إذن.** | 🔴 + قرار مطلوب |
|
||
| N6 | **حملات إعادة التفاعل فارغة** | `cron-worker.service.ts` ينادي `runCampaign(id, [])` بقائمة مستخدمين فارغة دائماً → «تنجح» بصفر إرسال. لا منطق استهداف مبني. | 🟡 |
|
||
| N7 | **بوتات O4 وهمية** | تُعلَّم `completed` فوراً برسالة ثابتة — هيكل CRUD فقط، لا تكامل منصّة فعلي. | 🟡 هيكل فقط |
|
||
| N8 | **بوابتا MTN/SyriaTel متخيَّلتان** | MTN خرجت من سوريا فعلياً، وواجهة SyriaTel e-payment المفترضة غير موثّقة — كلاهما سيسقط دائماً لوضع invoice (آمن تشغيلياً لكن ليس "منجزاً"). مسار سوريا الحقيقي المُثبَت هو SMS+Gemini (I8/P2). | 🟡 قرار مطلوب |
|
||
| N9 | **استحقاقات غير مقفلة** | كل الكونترولرات الجديدة (marketing/bots/tiers/transit/heatmap/surge) بلا `FeatureGuard` رغم استعماله في payments/chat/dispatch. ومفاتيح الكتالوج الجديد (`dynamic_pricing`/`geofence`/`negotiator`/`driver_assurance`/`coupons`) غائبة عن `FEATURES`، و`marketing_engine` بالعكس — بيع ميزة من الكتالوج لن يُفعّل شيئاً. | 🔴 أمني/مالي |
|
||
| N10 | **تفاصيل تكوين صغيرة** | `transit.serviceUrl` غير معرَّف في configuration.ts/.env.example (يضرب localhost:4020 داخل الحاوية) · مفتاح ترجمة `trip.audio_recording_started` غائب عن i18n · متغيرات `TRIP_SURGE_*` موثّقة في .env.example لكن `CRON_SCHEDULES` ثابتة بالكود ولا تقرأها. | ⏳ |
|
||
| N11 | **ما هو سليم فعلاً من الدفعة** | R1 (كنس searching) مبني صح بالكامل (UPDATE شرطي + تنظيف Redis + إشعار + i18n) ✓. بنية O5 (سجل تنفيذ لكل مهمة + orphan marking + concurrency=1) صحيحة بعد حل N3 ✓. B9 (نسخ تعرفة version+1) ✓. L3 الوزن في المحرك مع اختبارات ✓. P4 التقارير — كل أعمدة الرحلة/الدفع المستخدمة موجودة فعلاً ✓. N6/N7 (الكتالوج+الحاسبة، زرع idempotent) ✓. | ✅ |
|
||
|
||
**قرارات مطلوبة منك قبل المتابعة**: (أ) هل نُبقي N5 (tiers) رغم التأجيل السابق أم نُزيل المطابقة ونُبقي الأعمدة فقط؟ (ب) N8 — نحذف MTN/SyriaTel ونكتفي بـ`InvoiceAdapter` حتى واجهة موثّقة، أم نُبقيهما best-effort مع fallback واضح؟
|
||
|
||
**تطبيقا Flutter (نفس الدفعة، خارج نطاق هذا الملف الباك-إندي لكن يُذكر للربط)**: تطبيق السائق نظيف (`flutter analyze` = خطأ هامشي واحد). تطبيق الراكب **126 خطأ ترجمة** (مسارات استيراد models/tokens خاطئة، `flutter_gen` ملغى، `const` على دالة، خصوصية معرّفات `_` عبر ملفات) — تفصيل كامل ومعالجة في برومت منفصل سُلِّم لنموذج فلاتر. قناة FCM (docs/23 §15) غير موصولة إطلاقاً في التطبيقين رغم بناء نصف القرار (merger + مصالحة + فتح بارد).
|
||
|
||
---
|
||
|
||
## مؤجَّل عمداً (قرار المالك)
|
||
المفاوض الذكي · تدرّج السائق · خصم العمولة — **آخر شيء** (جديدة حتى على سيرو).
|
||
**Geofence** — مؤجَّل («لوقتها»)، موجود في سيرو للاستئناس.
|
||
|
||
---
|
||
|
||
## ترتيب التنفيذ المقترح
|
||
1. ~~**A** (الزمن الحقيقي + FCM + Redis + race)~~ ✅ **منفَّذة ومُثبَتة على السيرفر**.
|
||
2. ~~**I1** (سباق المحفظة)~~ ✅ **منفَّذة ومُثبَتة بتزامن حقيقي**.
|
||
3. ~~**G** (Redis خط أول: لغة/توكنات/tenant/تعرفة/تقييم/كاش الخرائط)~~ ✅ **منفَّذة**.
|
||
4. ~~**H** (المواقع: إيقاف الكتابة لكل نبضة + batching + tracks)~~ ✅ **منفَّذة** (عدا H6/H7 — تحليلات مؤجَّلة).
|
||
5. **B** — ✅ الشريحتان الأولى والثانية (B2·B3·B6·B7 · B4 stops · B5 جدولة · B8 مطابقة الوجهة). الباقي تحسينات: B9 (واجهة تعرفة) · B10 (كتالوج أنواع عالمي) · B11 (تعرفة بالوزن).
|
||
6. **I** الباقي (المدفوعات: جداول لكل طريقة + OTP/بصمة/HMAC للسحب).
|
||
7. **C** (بيانات المركبة والسائق + ai_data).
|
||
8. **D** (تطبيع الهاتف + بصمة الجهاز + HMAC).
|
||
9. **E** (الاحتيال) ثم **F**.
|