From a58f61106302f46dfe3be94a0eb2885c0d495b02 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Fri, 17 Jul 2026 19:49:10 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D8=A7=D9=84=D9=85=D8=AC=D9=85=D9=88?= =?UTF-8?q?=D8=B9=D8=A9=20B=20=D8=B4=D8=B1=D9=8A=D8=AD=D8=A9=202=20?= =?UTF-8?q?=E2=80=94=20=D9=86=D9=82=D8=A7=D8=B7=20=D8=AA=D9=88=D9=82=D9=81?= =?UTF-8?q?=20+=20=D8=AC=D8=AF=D9=88=D9=84=D8=A9=20+=20=D9=85=D8=B7=D8=A7?= =?UTF-8?q?=D8=A8=D9=82=D8=A9=20=D8=A7=D9=84=D9=88=D8=AC=D9=87=D8=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B4 نقاط التوقف: stops jsonb؛ المسار والتسعير يمرّان بالنقاط بالترتيب (أصل → توقف₁ → … → وجهة) بجمع أرجل المسار. بلا توقفات = رِجل واحدة كالسابق. B5 الحجز المسبق: scheduled_at + حالة scheduled جديدة (لا تدخل Redis ولا تُوزَّع الآن). ScheduledTripsSweeper يعمل داخل الـAPI لا الـworker — لأن الإطلاق يبثّ عروضاً عبر RealtimeGateway الموجود في الـAPI فقط. آمن مع عدة نسخ: releaseDueScheduled يستعمل UPDATE شرطياً (scheduled→searching) فلا تُطلَق رحلة مرتين. فهرس جزئي على scheduled المستحقة. B8 مطابقة الوجهة: is_destination_match + destination_match_discount + destination_match_discount_pct في التعرفة، يُطبَّق في settleFare. الخصم على الراكب والسائق يقبض المخفَّض كاملاً (يربح رحلة في طريقه أصلاً). مؤجَّل: محرّك المطابقة الذي يضبط العلم (وضع وجهة السائق). هجرة: TripStopsSchedulingMatch. حالة trip جديدة: scheduled (تنتقل إلى searching أو cancelled). Co-Authored-By: Claude Opus 4.8 --- backend/src/config/configuration.ts | 5 ++ .../1721920000000-TripStopsSchedulingMatch.ts | 34 +++++++ .../modules/tariff/entities/tariff.entity.ts | 3 + .../src/modules/trips/entities/trip.entity.ts | 28 ++++++ .../modules/trips/scheduled-trips.sweeper.ts | 46 ++++++++++ backend/src/modules/trips/trips.module.ts | 3 +- backend/src/modules/trips/trips.service.ts | 90 +++++++++++++++++-- docs/17-backend-backlog.md | 8 +- 8 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 backend/src/database/migrations/1721920000000-TripStopsSchedulingMatch.ts create mode 100644 backend/src/modules/trips/scheduled-trips.sweeper.ts diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index 69ad797..f3ec74e 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -70,6 +70,11 @@ export default () => ({ }, }, + // الرحلات: كل كم يفحص كنس الرحلات المجدولة (docs/17 — B5). + trips: { + scheduleSweepMs: parseInt(process.env.TRIP_SCHEDULE_SWEEP_MS ?? '30000', 10), + }, + nabeh: { baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com', email: process.env.NABEH_EMAIL ?? '', diff --git a/backend/src/database/migrations/1721920000000-TripStopsSchedulingMatch.ts b/backend/src/database/migrations/1721920000000-TripStopsSchedulingMatch.ts new file mode 100644 index 0000000..e5abef9 --- /dev/null +++ b/backend/src/database/migrations/1721920000000-TripStopsSchedulingMatch.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * شريحة B الثانية (docs/17 — B4/B5/B8): + * - نقاط التوقف (`stops` jsonb). + * - الحجز المسبق (`scheduled_at` + حالة `scheduled`). + * - مطابقة الوجهة (`is_destination_match` + `destination_match_discount`). + */ +export class TripStopsSchedulingMatch1721920000000 implements MigrationInterface { + public async up(q: QueryRunner): Promise { + await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS stops jsonb NOT NULL DEFAULT '[]'`); + await q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS scheduled_at timestamptz`); + await q.query( + `ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS is_destination_match boolean NOT NULL DEFAULT false`, + ); + await q.query( + `ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS destination_match_discount numeric(12,3)`, + ); + // الكنس يبحث عن scheduled المستحقة زمنياً — فهرس جزئي عليها فقط. + await q.query(` + CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_scheduled_due" + ON tripz_trips (scheduled_at) + WHERE status = 'scheduled' + `); + } + + public async down(q: QueryRunner): Promise { + await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trips_scheduled_due"`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS destination_match_discount`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS is_destination_match`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS scheduled_at`); + await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS stops`); + } +} diff --git a/backend/src/modules/tariff/entities/tariff.entity.ts b/backend/src/modules/tariff/entities/tariff.entity.ts index c65c5b3..8dff66f 100644 --- a/backend/src/modules/tariff/entities/tariff.entity.ts +++ b/backend/src/modules/tariff/entities/tariff.entity.ts @@ -69,4 +69,7 @@ export interface TariffDefinition { * **لا تُقتطع من أجرة السائق** — تُخصم من رصيده التشغيلي المدفوع سلفاً. */ commission?: { percent?: number; flat?: number; min?: number }; + + /** نسبة خصم مطابقة الوجهة (docs/17 — B8). غيابها = لا خصم. */ + destination_match_discount_pct?: number; } diff --git a/backend/src/modules/trips/entities/trip.entity.ts b/backend/src/modules/trips/entities/trip.entity.ts index 9f5a9f0..b635c11 100644 --- a/backend/src/modules/trips/entities/trip.entity.ts +++ b/backend/src/modules/trips/entities/trip.entity.ts @@ -8,6 +8,7 @@ import { } from 'typeorm'; export type TripStatus = + | 'scheduled' // محجوزة لوقت لاحق — لا تُوزَّع حتى يحين موعدها (docs/17 — B5) | 'searching' | 'assigned' | 'driver_arriving' @@ -19,6 +20,13 @@ export type TripStatus = | 'no_drivers' | 'expired'; +/** نقطة توقف ضمن الرحلة (docs/17 — B4). */ +export interface TripStop { + lat: number; + lng: number; + label?: string; +} + /** الرحلة. الجدول: tripz_trips. آلة الحالة مصدرها الباك إند (docs/02). */ @Entity('trips') @Index(['tenant_id', 'status']) @@ -115,6 +123,26 @@ export class Trip { @Column({ default: 'cash' }) payment_method: string; + // ---- نقاط التوقف (docs/17 — B4) ---- + // نقاط وسيطة بين الأصل والوجهة؛ المسار والتسعير يمرّان بها بالترتيب. + @Column({ type: 'jsonb', default: [] }) + stops: TripStop[]; + + // ---- الحجز المسبق (docs/17 — B5) ---- + // موعد الرحلة المجدولة؛ يبقى null للرحلات الفورية. الرحلة المجدولة تنتظر + // بحالة `scheduled` حتى يطلقها الـworker قبيل موعدها. + @Column({ type: 'timestamptz', nullable: true }) + scheduled_at: Date | null; + + // ---- مطابقة الوجهة (docs/17 — B8) ---- + // صحيحة حين تتوافق وجهة الرحلة مع اتجاه السائق (وضع «السائق عائد») — + // يُطبَّق خصم مطابقة الوجهة على الراكب. + @Column({ default: false }) + is_destination_match: boolean; + + @Column({ type: 'numeric', precision: 12, scale: 3, nullable: true }) + destination_match_discount: number | null; + @Column({ type: 'varchar', nullable: true }) cancelled_by: string | null; // rider | driver diff --git a/backend/src/modules/trips/scheduled-trips.sweeper.ts b/backend/src/modules/trips/scheduled-trips.sweeper.ts new file mode 100644 index 0000000..ab36d53 --- /dev/null +++ b/backend/src/modules/trips/scheduled-trips.sweeper.ts @@ -0,0 +1,46 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TripsService } from './trips.service'; + +/** + * يطلق الرحلات المجدولة التي حان موعدها (docs/17 — B5). + * + * يعمل **داخل عملية الـAPI** لا الـworker: الإطلاق يبثّ عروضاً عبر + * RealtimeGateway (WebSocket) الموجود هنا فقط. آمن مع عدة نسخ API لأن + * `releaseDueScheduled` يستعمل UPDATE شرطياً (scheduled→searching) فلا + * تُطلَق رحلة مرتين. + */ +@Injectable() +export class ScheduledTripsSweeper implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger('ScheduledTrips'); + private timer?: NodeJS.Timeout; + private running = false; + + constructor( + private readonly trips: TripsService, + private readonly config: ConfigService, + ) {} + + onModuleInit(): void { + const intervalMs = this.config.get('trips.scheduleSweepMs') ?? 30_000; + this.timer = setInterval(() => void this.tick(), intervalMs); + this.logger.log(`scheduled-trips sweeper up (every ${intervalMs}ms)`); + } + + onModuleDestroy(): void { + if (this.timer) clearInterval(this.timer); + } + + private async tick(): Promise { + if (this.running) return; // لا تتراكب دورة بطيئة مع التالية + this.running = true; + try { + const n = await this.trips.releaseDueScheduled(); + if (n > 0) this.logger.log(`released ${n} scheduled trip(s)`); + } catch (e: any) { + this.logger.error(`sweep failed: ${e?.message}`); + } finally { + this.running = false; + } + } +} diff --git a/backend/src/modules/trips/trips.module.ts b/backend/src/modules/trips/trips.module.ts index df7c1b0..679d527 100644 --- a/backend/src/modules/trips/trips.module.ts +++ b/backend/src/modules/trips/trips.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Trip } from './entities/trip.entity'; import { TripEvent } from './entities/trip-event.entity'; import { TripsService } from './trips.service'; +import { ScheduledTripsSweeper } from './scheduled-trips.sweeper'; import { TripStateService } from './trip-state.service'; import { TripsController } from './trips.controller'; import { MapsModule } from '../maps/maps.module'; @@ -31,7 +32,7 @@ import { CreditModule } from '../credit/credit.module'; CreditModule, ], controllers: [TripsController], - providers: [TripsService, TripStateService], + providers: [TripsService, TripStateService, ScheduledTripsSweeper], exports: [TripsService], }) export class TripsModule {} diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index a016028..d4a53d8 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -6,7 +6,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { LessThanOrEqual, Repository } from 'typeorm'; import { Trip, TripStatus } from './entities/trip.entity'; import { TripEvent } from './entities/trip-event.entity'; import { MapsService } from '../maps/maps.service'; @@ -33,6 +33,8 @@ const CANCEL_FEE_BY_STAGE: Partial> = { /** الانتقالات المسموحة في آلة حالة الرحلة (docs/02). */ const TRANSITIONS: Record = { + // المجدولة تُطلَق إلى searching عند موعدها، أو تُلغى قبله (docs/17 — B5). + scheduled: ['searching', 'cancelled', 'expired'], searching: ['assigned', 'cancelled', 'no_drivers', 'expired'], assigned: ['driver_arriving', 'cancelled'], driver_arriving: ['driver_arrived', 'cancelled'], @@ -55,6 +57,8 @@ export interface RequestTripDto { city?: string; is_round_trip?: boolean; rider_id?: string; // يُستخدم من dispatch لإنشاء رحلة نيابةً عن راكب + stops?: Array<{ lat: number; lng: number; label?: string }>; // docs/17 — B4 + scheduled_at?: string; // ISO — حجز مسبق (docs/17 — B5) } @Injectable() @@ -104,13 +108,29 @@ export class TripsService { const serviceClass = dto.service_class ?? 'economy'; const city = dto.city ?? 'default'; const isRound = !!dto.is_round_trip; - const oneWay = await this.maps.route(dto.origin, dto.destination); + + // المسار عبر نقاط التوقف بالترتيب (docs/17 — B4): أصل → توقف₁ → … → وجهة. + // بلا توقفات = رِجل واحدة كالسابق. + const stops = Array.isArray(dto.stops) ? dto.stops : []; + const legPoints = [dto.origin, ...stops, dto.destination]; + let legDistance = 0; + let legDuration = 0; + for (let i = 0; i < legPoints.length - 1; i++) { + const leg = await this.maps.route(legPoints[i], legPoints[i + 1]); + legDistance += leg.distanceKm; + legDuration += leg.durationMin; + } // ذهاب وعودة: يُضاعف المسافة والزمن للتسعير const route = { - distanceKm: isRound ? Number((oneWay.distanceKm * 2).toFixed(3)) : oneWay.distanceKm, - durationMin: isRound ? Number((oneWay.durationMin * 2).toFixed(1)) : oneWay.durationMin, + distanceKm: Number((isRound ? legDistance * 2 : legDistance).toFixed(3)), + durationMin: Number((isRound ? legDuration * 2 : legDuration).toFixed(1)), }; + // الحجز المسبق (docs/17 — B5): موعد مستقبلي → الرحلة تنتظر بحالة scheduled + // ولا تُوزَّع الآن؛ الـworker يطلقها قبيل موعدها. + const scheduledAt = dto.scheduled_at ? new Date(dto.scheduled_at) : null; + const isScheduled = scheduledAt != null && scheduledAt.getTime() > Date.now() + 60_000; + // تسعير (اختياري — لو ما في تعرفة مفعّلة نكمل بلا سعر مقفول) let quotedFare: number | null = null; let currency: string | null = null; @@ -149,7 +169,9 @@ export class TripsService { origin_lng: dto.origin.lng, dest_lat: dto.destination.lat, dest_lng: dto.destination.lng, - status: 'searching', + stops, + scheduled_at: scheduledAt, + status: isScheduled ? 'scheduled' : 'searching', distance_km: route.distanceKm, duration_min: route.durationMin, tariff_id: tariffId, @@ -164,6 +186,13 @@ export class TripsService { payment_method: (dto as any).payment_method ?? 'cash', }); trip = await this.trips.save(trip); + + // المجدولة لا تدخل Redis ولا تُوزَّع الآن — تنتظر الـworker (docs/17 — B5). + if (isScheduled) { + await this.recordEvent(trip, null, 'scheduled', 'rider'); + return { trip, offeredDrivers: 0, scheduled: true }; + } + await this.state.save(trip); await this.recordEvent(trip, null, 'searching', 'rider'); @@ -174,6 +203,42 @@ export class TripsService { return { trip, offeredDrivers: offered }; } + /** + * إطلاق الرحلات المجدولة التي حان موعدها (docs/17 — B5). يناديها الـworker + * دورياً. كل رحلة تنتقل `scheduled → searching` وتُوزَّع كرحلة فورية. + * يرجع عدد ما أُطلق. + */ + async releaseDueScheduled(tenantId?: string, leadMinutes = 10): Promise { + const dueBefore = new Date(Date.now() + leadMinutes * 60_000); + const where: any = { status: 'scheduled' }; + if (tenantId) where.tenant_id = tenantId; + const due = await this.trips.find({ + where: { ...where, scheduled_at: LessThanOrEqual(dueBefore) }, + take: 100, + }); + + let released = 0; + for (const trip of due) { + // UPDATE شرطي: نسختا worker متزامنتان لا تطلقان نفس الرحلة مرتين. + const res = await this.trips.update( + { id: trip.id, status: 'scheduled' }, + { status: 'searching' }, + ); + if (!res.affected) continue; + + trip.status = 'searching'; + await this.state.save(trip); + await this.recordEvent(trip, 'scheduled', 'searching', 'rider'); + await this.offerToNearbyDrivers(trip.tenant_id, trip, { + lat: trip.origin_lat, + lng: trip.origin_lng, + }); + this.gateway.dispatch(trip.tenant_id, 'trip:new', { tripId: trip.id }); + released++; + } + return released; + } + /** قبول سائق للرحلة (أول قبول يفوز — docs/17 A4). */ async accept(tenantId: string, tripId: string, driverUserId: string) { const driver = await this.drivers.findByUser(tenantId, driverUserId); @@ -554,18 +619,29 @@ export class TripsService { // الانتظار ومشوار الوصول حُسبا وخُزّنا سابقاً — نقرأهما من الصف. const row = await this.trips.findOne({ where: { tenant_id: tenantId, id: tripId }, - select: { waiting_charge: true, pickup_charge: true }, + select: { waiting_charge: true, pickup_charge: true, is_destination_match: true }, }); const base = snapshot.quoted_fare ?? 0; const waiting = Number(row?.waiting_charge ?? 0); // مشوار الوصول لا يدخل أجرة الرحلة — هو تعويض إلغاء فقط (docs/18، تصحيح B3). - const total = Number((base + waiting).toFixed(3)); + const gross = base + waiting; const def = await this.tariffDef(tenantId, snapshot); + + // خصم مطابقة الوجهة (docs/17 — B8): الرحلة توافق اتجاه السائق فيُخفَّض + // على الراكب. الخصم من أجرة الراكب لا من دخل السائق — السائق يقبض الأجرة + // المخفَّضة كاملة (يربح رحلةً في طريقه أصلاً). + let discount = 0; + if (row?.is_destination_match && def?.destination_match_discount_pct) { + discount = Number((gross * (def.destination_match_discount_pct / 100)).toFixed(3)); + } + const total = Number((gross - discount).toFixed(3)); + const commission = def ? TariffEngine.commission(def, total) : { amount: 0, rate: 0 }; return { final_fare: total, + destination_match_discount: discount || null, // السائق يقبض ما يدفعه الراكب **كاملاً** (docs/18). العمولة تُخصم من // رصيده التشغيلي لا من هذا المبلغ. price_for_passenger: total, diff --git a/docs/17-backend-backlog.md b/docs/17-backend-backlog.md index df26799..e2da844 100644 --- a/docs/17-backend-backlog.md +++ b/docs/17-backend-backlog.md @@ -32,9 +32,9 @@ | 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)** | نقطتا توقف ضمن الرحلة (كما في سيرو/شير). | ⏳ الشريحة التالية | -| B5 | **حجز مسبق/جدولة** | جداول الرحلة (`date`/`time`/`endtime` في سيرو). | ⏳ الشريحة التالية | -| B8 | **مطابقة الوجهة** | `is_destination_match` + خصم الراكب. | ⏳ الشريحة التالية | +| 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 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. | ⏳ | @@ -287,7 +287,7 @@ 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. +5. **B** — ✅ الشريحتان الأولى والثانية (B2·B3·B6·B7 · B4 stops · B5 جدولة · B8 مطابقة الوجهة). الباقي تحسينات: B9 (واجهة تعرفة) · B10 (كتالوج أنواع عالمي) · B11 (تعرفة بالوزن). 6. **I** الباقي (المدفوعات: جداول لكل طريقة + OTP/بصمة/HMAC للسحب). 7. **C** (بيانات المركبة والسائق + ai_data). 8. **D** (تطبيع الهاتف + بصمة الجهاز + HMAC).