feat: المجموعة B شريحة 2 — نقاط توقف + جدولة + مطابقة الوجهة
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c2a4b9d1a6
commit
a58f611063
@@ -70,6 +70,11 @@ export default () => ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// الرحلات: كل كم يفحص كنس الرحلات المجدولة (docs/17 — B5).
|
||||||
|
trips: {
|
||||||
|
scheduleSweepMs: parseInt(process.env.TRIP_SCHEDULE_SWEEP_MS ?? '30000', 10),
|
||||||
|
},
|
||||||
|
|
||||||
nabeh: {
|
nabeh: {
|
||||||
baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com',
|
baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com',
|
||||||
email: process.env.NABEH_EMAIL ?? '',
|
email: process.env.NABEH_EMAIL ?? '',
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,4 +69,7 @@ export interface TariffDefinition {
|
|||||||
* **لا تُقتطع من أجرة السائق** — تُخصم من رصيده التشغيلي المدفوع سلفاً.
|
* **لا تُقتطع من أجرة السائق** — تُخصم من رصيده التشغيلي المدفوع سلفاً.
|
||||||
*/
|
*/
|
||||||
commission?: { percent?: number; flat?: number; min?: number };
|
commission?: { percent?: number; flat?: number; min?: number };
|
||||||
|
|
||||||
|
/** نسبة خصم مطابقة الوجهة (docs/17 — B8). غيابها = لا خصم. */
|
||||||
|
destination_match_discount_pct?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
export type TripStatus =
|
export type TripStatus =
|
||||||
|
| 'scheduled' // محجوزة لوقت لاحق — لا تُوزَّع حتى يحين موعدها (docs/17 — B5)
|
||||||
| 'searching'
|
| 'searching'
|
||||||
| 'assigned'
|
| 'assigned'
|
||||||
| 'driver_arriving'
|
| 'driver_arriving'
|
||||||
@@ -19,6 +20,13 @@ export type TripStatus =
|
|||||||
| 'no_drivers'
|
| 'no_drivers'
|
||||||
| 'expired';
|
| 'expired';
|
||||||
|
|
||||||
|
/** نقطة توقف ضمن الرحلة (docs/17 — B4). */
|
||||||
|
export interface TripStop {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** الرحلة. الجدول: tripz_trips. آلة الحالة مصدرها الباك إند (docs/02). */
|
/** الرحلة. الجدول: tripz_trips. آلة الحالة مصدرها الباك إند (docs/02). */
|
||||||
@Entity('trips')
|
@Entity('trips')
|
||||||
@Index(['tenant_id', 'status'])
|
@Index(['tenant_id', 'status'])
|
||||||
@@ -115,6 +123,26 @@ export class Trip {
|
|||||||
@Column({ default: 'cash' })
|
@Column({ default: 'cash' })
|
||||||
payment_method: string;
|
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 })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
cancelled_by: string | null; // rider | driver
|
cancelled_by: string | null; // rider | driver
|
||||||
|
|
||||||
|
|||||||
@@ -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<number>('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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { Trip } from './entities/trip.entity';
|
import { Trip } from './entities/trip.entity';
|
||||||
import { TripEvent } from './entities/trip-event.entity';
|
import { TripEvent } from './entities/trip-event.entity';
|
||||||
import { TripsService } from './trips.service';
|
import { TripsService } from './trips.service';
|
||||||
|
import { ScheduledTripsSweeper } from './scheduled-trips.sweeper';
|
||||||
import { TripStateService } from './trip-state.service';
|
import { TripStateService } from './trip-state.service';
|
||||||
import { TripsController } from './trips.controller';
|
import { TripsController } from './trips.controller';
|
||||||
import { MapsModule } from '../maps/maps.module';
|
import { MapsModule } from '../maps/maps.module';
|
||||||
@@ -31,7 +32,7 @@ import { CreditModule } from '../credit/credit.module';
|
|||||||
CreditModule,
|
CreditModule,
|
||||||
],
|
],
|
||||||
controllers: [TripsController],
|
controllers: [TripsController],
|
||||||
providers: [TripsService, TripStateService],
|
providers: [TripsService, TripStateService, ScheduledTripsSweeper],
|
||||||
exports: [TripsService],
|
exports: [TripsService],
|
||||||
})
|
})
|
||||||
export class TripsModule {}
|
export class TripsModule {}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||||
import { Trip, TripStatus } from './entities/trip.entity';
|
import { Trip, TripStatus } from './entities/trip.entity';
|
||||||
import { TripEvent } from './entities/trip-event.entity';
|
import { TripEvent } from './entities/trip-event.entity';
|
||||||
import { MapsService } from '../maps/maps.service';
|
import { MapsService } from '../maps/maps.service';
|
||||||
@@ -33,6 +33,8 @@ const CANCEL_FEE_BY_STAGE: Partial<Record<TripStatus, number>> = {
|
|||||||
|
|
||||||
/** الانتقالات المسموحة في آلة حالة الرحلة (docs/02). */
|
/** الانتقالات المسموحة في آلة حالة الرحلة (docs/02). */
|
||||||
const TRANSITIONS: Record<TripStatus, TripStatus[]> = {
|
const TRANSITIONS: Record<TripStatus, TripStatus[]> = {
|
||||||
|
// المجدولة تُطلَق إلى searching عند موعدها، أو تُلغى قبله (docs/17 — B5).
|
||||||
|
scheduled: ['searching', 'cancelled', 'expired'],
|
||||||
searching: ['assigned', 'cancelled', 'no_drivers', 'expired'],
|
searching: ['assigned', 'cancelled', 'no_drivers', 'expired'],
|
||||||
assigned: ['driver_arriving', 'cancelled'],
|
assigned: ['driver_arriving', 'cancelled'],
|
||||||
driver_arriving: ['driver_arrived', 'cancelled'],
|
driver_arriving: ['driver_arrived', 'cancelled'],
|
||||||
@@ -55,6 +57,8 @@ export interface RequestTripDto {
|
|||||||
city?: string;
|
city?: string;
|
||||||
is_round_trip?: boolean;
|
is_round_trip?: boolean;
|
||||||
rider_id?: string; // يُستخدم من dispatch لإنشاء رحلة نيابةً عن راكب
|
rider_id?: string; // يُستخدم من dispatch لإنشاء رحلة نيابةً عن راكب
|
||||||
|
stops?: Array<{ lat: number; lng: number; label?: string }>; // docs/17 — B4
|
||||||
|
scheduled_at?: string; // ISO — حجز مسبق (docs/17 — B5)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -104,13 +108,29 @@ export class TripsService {
|
|||||||
const serviceClass = dto.service_class ?? 'economy';
|
const serviceClass = dto.service_class ?? 'economy';
|
||||||
const city = dto.city ?? 'default';
|
const city = dto.city ?? 'default';
|
||||||
const isRound = !!dto.is_round_trip;
|
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 = {
|
const route = {
|
||||||
distanceKm: isRound ? Number((oneWay.distanceKm * 2).toFixed(3)) : oneWay.distanceKm,
|
distanceKm: Number((isRound ? legDistance * 2 : legDistance).toFixed(3)),
|
||||||
durationMin: isRound ? Number((oneWay.durationMin * 2).toFixed(1)) : oneWay.durationMin,
|
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 quotedFare: number | null = null;
|
||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
@@ -149,7 +169,9 @@ export class TripsService {
|
|||||||
origin_lng: dto.origin.lng,
|
origin_lng: dto.origin.lng,
|
||||||
dest_lat: dto.destination.lat,
|
dest_lat: dto.destination.lat,
|
||||||
dest_lng: dto.destination.lng,
|
dest_lng: dto.destination.lng,
|
||||||
status: 'searching',
|
stops,
|
||||||
|
scheduled_at: scheduledAt,
|
||||||
|
status: isScheduled ? 'scheduled' : 'searching',
|
||||||
distance_km: route.distanceKm,
|
distance_km: route.distanceKm,
|
||||||
duration_min: route.durationMin,
|
duration_min: route.durationMin,
|
||||||
tariff_id: tariffId,
|
tariff_id: tariffId,
|
||||||
@@ -164,6 +186,13 @@ export class TripsService {
|
|||||||
payment_method: (dto as any).payment_method ?? 'cash',
|
payment_method: (dto as any).payment_method ?? 'cash',
|
||||||
});
|
});
|
||||||
trip = await this.trips.save(trip);
|
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.state.save(trip);
|
||||||
await this.recordEvent(trip, null, 'searching', 'rider');
|
await this.recordEvent(trip, null, 'searching', 'rider');
|
||||||
|
|
||||||
@@ -174,6 +203,42 @@ export class TripsService {
|
|||||||
return { trip, offeredDrivers: offered };
|
return { trip, offeredDrivers: offered };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* إطلاق الرحلات المجدولة التي حان موعدها (docs/17 — B5). يناديها الـworker
|
||||||
|
* دورياً. كل رحلة تنتقل `scheduled → searching` وتُوزَّع كرحلة فورية.
|
||||||
|
* يرجع عدد ما أُطلق.
|
||||||
|
*/
|
||||||
|
async releaseDueScheduled(tenantId?: string, leadMinutes = 10): Promise<number> {
|
||||||
|
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). */
|
/** قبول سائق للرحلة (أول قبول يفوز — docs/17 A4). */
|
||||||
async accept(tenantId: string, tripId: string, driverUserId: string) {
|
async accept(tenantId: string, tripId: string, driverUserId: string) {
|
||||||
const driver = await this.drivers.findByUser(tenantId, driverUserId);
|
const driver = await this.drivers.findByUser(tenantId, driverUserId);
|
||||||
@@ -554,18 +619,29 @@ export class TripsService {
|
|||||||
// الانتظار ومشوار الوصول حُسبا وخُزّنا سابقاً — نقرأهما من الصف.
|
// الانتظار ومشوار الوصول حُسبا وخُزّنا سابقاً — نقرأهما من الصف.
|
||||||
const row = await this.trips.findOne({
|
const row = await this.trips.findOne({
|
||||||
where: { tenant_id: tenantId, id: tripId },
|
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 base = snapshot.quoted_fare ?? 0;
|
||||||
const waiting = Number(row?.waiting_charge ?? 0);
|
const waiting = Number(row?.waiting_charge ?? 0);
|
||||||
// مشوار الوصول لا يدخل أجرة الرحلة — هو تعويض إلغاء فقط (docs/18، تصحيح B3).
|
// مشوار الوصول لا يدخل أجرة الرحلة — هو تعويض إلغاء فقط (docs/18، تصحيح B3).
|
||||||
const total = Number((base + waiting).toFixed(3));
|
const gross = base + waiting;
|
||||||
|
|
||||||
const def = await this.tariffDef(tenantId, snapshot);
|
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 };
|
const commission = def ? TariffEngine.commission(def, total) : { amount: 0, rate: 0 };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
final_fare: total,
|
final_fare: total,
|
||||||
|
destination_match_discount: discount || null,
|
||||||
// السائق يقبض ما يدفعه الراكب **كاملاً** (docs/18). العمولة تُخصم من
|
// السائق يقبض ما يدفعه الراكب **كاملاً** (docs/18). العمولة تُخصم من
|
||||||
// رصيده التشغيلي لا من هذا المبلغ.
|
// رصيده التشغيلي لا من هذا المبلغ.
|
||||||
price_for_passenger: total,
|
price_for_passenger: total,
|
||||||
|
|||||||
@@ -32,9 +32,9 @@
|
|||||||
| B3 | **احتساب مشوار الوصول للراكب** | يُقاس من موقع السائق **الحيّ لحظة القبول** حتى نقطة الالتقاط. | 🟡 **القياس ✅ والقاعدة ❌** — راجع التصحيح أدناه |
|
| B3 | **احتساب مشوار الوصول للراكب** | يُقاس من موقع السائق **الحيّ لحظة القبول** حتى نقطة الالتقاط. | 🟡 **القياس ✅ والقاعدة ❌** — راجع التصحيح أدناه |
|
||||||
| B6 | **فصل السعر** | `price_for_passenger` · `price_for_driver` · `commission_amount` · `commission_rate`. العمولة تُعرَّف في التعرفة: `commission: { percent, flat, min }`، ولا تتجاوز الأجرة أبداً. | ✅ + **تسوية المحفظة صُحّحت**: كانت تُضيف للسائق كامل أجرة الراكب — أي أن العمولة كانت تضيع |
|
| 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`). | ✅ + كشف «الإنهاء السريع» صار يقيس من **بدء** الرحلة لا إسنادها |
|
| B7 | **طوابع زمنية دقيقة** | `driver_going_at` (`DriverIsGoingToPassenger`) · `arrived_at` · `started_at` (`rideTimeStart`) · `completed_at` (`rideTimeFinish`). | ✅ + كشف «الإنهاء السريع» صار يقيس من **بدء** الرحلة لا إسنادها |
|
||||||
| B4 | **نقاط توقف (stops)** | نقطتا توقف ضمن الرحلة (كما في سيرو/شير). | ⏳ الشريحة التالية |
|
| B4 | **نقاط توقف (stops)** | ✅ `stops` jsonb؛ المسار والتسعير يمرّان بالنقاط بالترتيب (أصل → توقف₁ → … → وجهة) بجمع أرجل المسار. |
|
||||||
| B5 | **حجز مسبق/جدولة** | جداول الرحلة (`date`/`time`/`endtime` في سيرو). | ⏳ الشريحة التالية |
|
| B5 | **حجز مسبق/جدولة** | ✅ `scheduled_at` + حالة `scheduled` (لا تدخل Redis ولا تُوزَّع). `ScheduledTripsSweeper` **داخل الـAPI** (يملك الـgateway) يطلقها قبيل موعدها بـUPDATE شرطي (آمن عبر النسخ). |
|
||||||
| B8 | **مطابقة الوجهة** | `is_destination_match` + خصم الراكب. | ⏳ الشريحة التالية |
|
| B8 | **مطابقة الوجهة** | 🟡 الحقول والخصم منفَّذان: `is_destination_match` + `destination_match_discount` + `destination_match_discount_pct` في التعرفة، يُطبَّق في التسوية (الخصم على الراكب، السائق يقبض المخفَّض كاملاً). **مؤجَّل**: محرّك المطابقة الذي **يضبط** العلم (وضع «وجهة السائق») — يحتاج تخزين وجهة السائق + مطابقة اتجاهية في محرّك التوزيع. |
|
||||||
| B9 | **تعديل التعرفة من لوحة الأدمن** | جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). | ⏳ |
|
| B9 | **تعديل التعرفة من لوحة الأدمن** | جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). | ⏳ |
|
||||||
| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). | ⏳ |
|
| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). | ⏳ |
|
||||||
| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. | ⏳ |
|
| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. | ⏳ |
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
2. ~~**I1** (سباق المحفظة)~~ ✅ **منفَّذة ومُثبَتة بتزامن حقيقي**.
|
2. ~~**I1** (سباق المحفظة)~~ ✅ **منفَّذة ومُثبَتة بتزامن حقيقي**.
|
||||||
3. ~~**G** (Redis خط أول: لغة/توكنات/tenant/تعرفة/تقييم/كاش الخرائط)~~ ✅ **منفَّذة**.
|
3. ~~**G** (Redis خط أول: لغة/توكنات/tenant/تعرفة/تقييم/كاش الخرائط)~~ ✅ **منفَّذة**.
|
||||||
4. ~~**H** (المواقع: إيقاف الكتابة لكل نبضة + batching + tracks)~~ ✅ **منفَّذة** (عدا H6/H7 — تحليلات مؤجَّلة).
|
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 للسحب).
|
6. **I** الباقي (المدفوعات: جداول لكل طريقة + OTP/بصمة/HMAC للسحب).
|
||||||
7. **C** (بيانات المركبة والسائق + ai_data).
|
7. **C** (بيانات المركبة والسائق + ai_data).
|
||||||
8. **D** (تطبيع الهاتف + بصمة الجهاز + HMAC).
|
8. **D** (تطبيع الهاتف + بصمة الجهاز + HMAC).
|
||||||
|
|||||||
Reference in New Issue
Block a user