feat: المجموعة B (شريحة 1) — فصل السعر + الانتظار + مشوار الوصول + الطوابع

B6 فصل السعر (الأهم — أساس كل عمل مالي لاحق):
- price_for_passenger / price_for_driver / commission_amount / commission_rate
- العمولة تُعرَّف في التعرفة: commission { percent, flat, min }، ولا تتجاوز
  الأجرة أبداً (دخل السائق لا يصير سالباً)
- **إصلاح**: تسوية المحفظة كانت تُضيف للسائق كامل أجرة الراكب — أي أن
  العمولة كانت تضيع. صار الراكب يُخصم passenger والسائق يُضاف له driver
- العرض يحمل earnings (ما يقبضه السائق) لا أجرة الراكب

B2 الانتظار: يبدأ عند driver_arrived ويُحتسب عند in_progress. أول 5 دقائق
مجانية (free_waiting_min قابل للضبط لكل تعرفة)، والزائد بسعر النافذة.

B3 مشوار الوصول: يُقاس من موقع السائق الحيّ لحظة القبول حتى نقطة الالتقاط.
لا يُحتسب على الراكب ما لم يُفعّله المالك (pickup_leg.charge) — تفعيله يرفع
كل أجرة فهو قرار لا افتراض صامت. فشل القياس لا يُفشل القبول.

B7 الطوابع: driver_going_at · arrived_at · started_at (+ completed_at).
كشف «الإنهاء السريع» صار يقيس من بدء الرحلة لا إسنادها — المشوار والانتظار
ليسا جزءاً من زمنها.

B1: كان منفَّذاً أصلاً — driver_arrived و in_progress حالتان منفصلتان منذ P1.

معلَّق على I2: العمولة محسوبة ومسجّلة لكنها لا تُقيَّد في محفظة المنصة
(لم تُبنَ بعد).

هجرة: TripPricingAndTimestamps (تملأ الرحلات القائمة: دفع = قبض).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 03:11:36 +03:00
co-authored by Claude Opus 4.8
parent 7e24c63dd8
commit f3fdc1bc20
9 changed files with 468 additions and 34 deletions
@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* نموذج الرحلة (docs/17 — B2/B3/B6/B7):
* - فصل السعر: ما يدفعه الراكب مقابل ما يقبضه السائق + العمولة.
* - الانتظار: الدقائق ورسمها.
* - مشوار وصول السائق للراكب: مسافته وزمنه ورسمه.
* - طوابع دقيقة: انطلاق نحو الراكب · الوصول · بدء الرحلة.
*/
export class TripPricingAndTimestamps1721840000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
const add = (col: string, type: string) =>
q.query(`ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS ${col} ${type}`);
await add('price_for_passenger', 'numeric(12,3)');
await add('price_for_driver', 'numeric(12,3)');
await add('commission_amount', 'numeric(12,3)');
await add('commission_rate', 'numeric(5,2)');
await add('waiting_min', 'numeric(6,1)');
await add('waiting_charge', 'numeric(12,3)');
await add('pickup_distance_km', 'numeric(8,3)');
await add('pickup_duration_min', 'numeric(8,1)');
await add('pickup_charge', 'numeric(12,3)');
await add('driver_going_at', 'timestamptz');
await add('arrived_at', 'timestamptz');
await add('started_at', 'timestamptz');
// رحلات قائمة: الراكب دفع = السائق قبض (لم تكن هناك عمولة أصلاً).
await q.query(`
UPDATE tripz_trips
SET price_for_passenger = COALESCE(price_for_passenger, final_fare, quoted_fare),
price_for_driver = COALESCE(price_for_driver, final_fare, quoted_fare)
WHERE quoted_fare IS NOT NULL OR final_fare IS NOT NULL
`);
}
public async down(q: QueryRunner): Promise<void> {
const drop = (col: string) =>
q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS ${col}`);
for (const c of [
'started_at', 'arrived_at', 'driver_going_at',
'pickup_charge', 'pickup_duration_min', 'pickup_distance_km',
'waiting_charge', 'waiting_min',
'commission_rate', 'commission_amount', 'price_for_driver', 'price_for_passenger',
]) {
await drop(c);
}
}
}
@@ -58,4 +58,19 @@ export interface TariffDefinition {
booking_fee?: number;
min_fare?: number;
surge?: { enabled: boolean; multiplier?: number; max?: number };
/** دقائق انتظار مجانية بعد وصول السائق قبل بدء العدّ (docs/17 — B2). القانون: 5. */
free_waiting_min?: number;
/**
* فصل السعر (docs/17 — B6): ما يدفعه الراكب مقابل ما يقبضه السائق.
* العمولة = percent% من الأجرة + flat، وبحدّ أدنى min. غيابها = بلا عمولة.
*/
commission?: { percent?: number; flat?: number; min?: number };
/**
* مشوار وصول السائق للراكب (docs/17 — B3). `charge: false` افتراضاً —
* تفعيله يرفع الأجرة على كل رحلة، فهو قرار مالك لا افتراض صامت.
*/
pickup_leg?: { charge: boolean };
}
@@ -0,0 +1,101 @@
import { TariffEngine } from './tariff.engine';
import { TariffDefinition } from './entities/tariff.entity';
const DAY = new Date('2026-07-17T09:00:00Z'); // داخل نافذة النهار
function def(extra: Partial<TariffDefinition> = {}): TariffDefinition {
return {
currency: 'JOD',
mode: 'time_and_distance',
windows: [
{ name: 'day', from: '06:00', to: '22:00', flag: 0.39, per_km: 0.28, per_min: 0.06, per_min_waiting: 0.5 },
{ name: 'night', from: '22:00', to: '06:00', flag: 0.4, per_km: 0.33, per_min: 0.07, per_min_waiting: 1.0 },
],
...extra,
};
}
describe('TariffEngine.waitingCharge — الانتظار (B2)', () => {
it('أول 5 دقائق مجانية بحكم القانون', () => {
const r = TariffEngine.waitingCharge(def(), 5, DAY);
expect(r.freeMin).toBe(5);
expect(r.billableMin).toBe(0);
expect(r.charge).toBe(0);
});
it('ما زاد عن المجاني يُحسب بسعر انتظار النافذة', () => {
const r = TariffEngine.waitingCharge(def(), 9, DAY);
expect(r.billableMin).toBe(4);
expect(r.charge).toBeCloseTo(4 * 0.5);
});
it('انتظار أقل من المجاني لا يُنتج رصيداً سالباً', () => {
expect(TariffEngine.waitingCharge(def(), 2, DAY).charge).toBe(0);
});
it('الدقائق المجانية قابلة للضبط لكل تعرفة', () => {
const r = TariffEngine.waitingCharge(def({ free_waiting_min: 10 }), 9, DAY);
expect(r.billableMin).toBe(0);
});
it('نافذة الليل أغلى', () => {
const night = new Date('2026-07-17T23:00:00Z');
expect(TariffEngine.waitingCharge(def(), 9, night).charge).toBeCloseTo(4 * 1.0);
});
});
describe('TariffEngine.split — فصل السعر (B6)', () => {
it('بلا عمولة: السائق يقبض كل ما دفعه الراكب', () => {
const s = TariffEngine.split(def(), 10);
expect(s.forPassenger).toBe(10);
expect(s.forDriver).toBe(10);
expect(s.commission).toBe(0);
});
it('عمولة نسبية', () => {
const s = TariffEngine.split(def({ commission: { percent: 15 } }), 10);
expect(s.commission).toBeCloseTo(1.5);
expect(s.forDriver).toBeCloseTo(8.5);
expect(s.rate).toBe(15);
});
it('نسبة + مقطوع', () => {
const s = TariffEngine.split(def({ commission: { percent: 10, flat: 0.25 } }), 10);
expect(s.commission).toBeCloseTo(1.25);
expect(s.forDriver).toBeCloseTo(8.75);
});
it('حدّ أدنى للعمولة', () => {
const s = TariffEngine.split(def({ commission: { percent: 10, min: 0.5 } }), 2);
expect(s.commission).toBeCloseTo(0.5); // 0.2 أقل من الحد
expect(s.forDriver).toBeCloseTo(1.5);
});
it('العمولة لا تتجاوز الأجرة — دخل السائق لا يصير سالباً', () => {
const s = TariffEngine.split(def({ commission: { percent: 50, min: 5 } }), 2);
expect(s.commission).toBe(2);
expect(s.forDriver).toBe(0);
});
it('الراكب + السائق يتوازنان دائماً', () => {
for (const total of [0, 1.337, 10, 99.999]) {
const s = TariffEngine.split(def({ commission: { percent: 15 } }), total);
expect(s.forDriver + s.commission).toBeCloseTo(s.forPassenger, 3);
}
});
});
describe('TariffEngine.pickupCharge — مشوار الوصول (B3)', () => {
it('لا يُحتسب ما لم يُفعَّل صراحةً', () => {
expect(TariffEngine.pickupCharge(def(), 5, 10, DAY)).toBe(0);
});
it('عند تفعيله يُحسب بمسافة النافذة وزمنها', () => {
const charge = TariffEngine.pickupCharge(def({ pickup_leg: { charge: true } }), 5, 10, DAY);
expect(charge).toBeCloseTo(5 * 0.28 + 10 * 0.06);
});
it('لا رسم على مشوار صفري', () => {
expect(TariffEngine.pickupCharge(def({ pickup_leg: { charge: true } }), 0, 0, DAY)).toBe(0);
});
});
@@ -77,6 +77,69 @@ export class TariffEngine {
};
}
/**
* رسم الانتظار بعد وصول السائق (docs/17 — B2). الدقائق المجانية (5 قانوناً)
* تُخصم أولاً، والباقي يُحسب بسعر انتظار النافذة الفعّالة.
*/
static waitingCharge(
def: TariffDefinition,
waitedMin: number,
at: Date = new Date(),
): { freeMin: number; billableMin: number; charge: number } {
const win = TariffEngine.pickWindow(def.windows, at);
const freeMin = def.free_waiting_min ?? 5;
const billableMin = Math.max(0, (waitedMin ?? 0) - freeMin);
return {
freeMin,
billableMin: TariffEngine.n(billableMin),
charge: TariffEngine.n(billableMin * (win.per_min_waiting ?? 0)),
};
}
/**
* رسم مشوار وصول السائق للراكب (docs/17 — B3).
* يرجع صفراً ما لم يُفعِّله المالك صراحةً في التعرفة.
*/
static pickupCharge(
def: TariffDefinition,
distanceKm: number,
durationMin: number,
at: Date = new Date(),
): number {
if (!def.pickup_leg?.charge) return 0;
const win = TariffEngine.pickWindow(def.windows, at);
const charge =
Math.max(0, distanceKm ?? 0) * win.per_km + Math.max(0, durationMin ?? 0) * win.per_min;
return TariffEngine.n(charge);
}
/**
* فصل السعر (docs/17 — B6): يقسم ما يدفعه الراكب إلى عمولة المنصة وحصة
* السائق. العمولة لا تتجاوز الأجرة مهما كانت الإعدادات — وإلا صار دخل
* السائق سالباً.
*/
static split(
def: TariffDefinition,
passengerTotal: number,
): { forPassenger: number; forDriver: number; commission: number; rate: number } {
const total = Math.max(0, passengerTotal ?? 0);
const c = def.commission;
if (!c) {
return { forPassenger: TariffEngine.n(total), forDriver: TariffEngine.n(total), commission: 0, rate: 0 };
}
const percent = c.percent ?? 0;
let commission = total * (percent / 100) + (c.flat ?? 0);
if (c.min != null && commission < c.min) commission = c.min;
if (commission > total) commission = total;
commission = TariffEngine.n(commission);
return {
forPassenger: TariffEngine.n(total),
forDriver: TariffEngine.n(total - commission),
commission,
rate: percent,
};
}
private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow {
const mins = at.getUTCHours() * 60 + at.getUTCMinutes();
for (const w of windows) {
@@ -79,6 +79,36 @@ export class Trip {
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
final_fare: number | null;
// ---- فصل السعر (docs/17 — B6): ما يدفعه الراكب ≠ ما يقبضه السائق ----
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
price_for_passenger: number | null;
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
price_for_driver: number | null;
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
commission_amount: number | null;
@Column({ type: 'numeric', precision: 5, scale: 2, nullable: true })
commission_rate: number | null;
// ---- الانتظار (docs/17 — B2) ----
@Column({ type: 'numeric', precision: 6, scale: 1, nullable: true })
waiting_min: number | null;
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
waiting_charge: number | null;
// ---- مشوار وصول السائق للراكب (docs/17 — B3) ----
@Column({ type: 'numeric', precision: 8, scale: 3, nullable: true })
pickup_distance_km: number | null;
@Column({ type: 'numeric', precision: 8, scale: 1, nullable: true })
pickup_duration_min: number | null;
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
pickup_charge: number | null;
@Column({ nullable: true })
currency: string;
@@ -91,12 +121,26 @@ export class Trip {
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
cancel_fee: number | null;
// ---- طوابع دقيقة (docs/17 — B7؛ مقابلها في سيرو بين قوسين) ----
@CreateDateColumn()
requested_at: Date;
@Column({ type: 'timestamptz', nullable: true })
assigned_at: Date | null;
/** السائق انطلق نحو الراكب (`DriverIsGoingToPassenger`). */
@Column({ type: 'timestamptz', nullable: true })
driver_going_at: Date | null;
/** السائق وصل نقطة الالتقاط — بداية عدّ الانتظار. */
@Column({ type: 'timestamptz', nullable: true })
arrived_at: Date | null;
/** الرحلة بدأت فعلاً — الراكب في السيارة (`rideTimeStart`). */
@Column({ type: 'timestamptz', nullable: true })
started_at: Date | null;
/** الرحلة انتهت (`rideTimeFinish`). */
@Column({ type: 'timestamptz', nullable: true })
completed_at: Date | null;
@@ -24,6 +24,8 @@ function makeTrip(overrides: Partial<Trip> = {}): Trip {
currency: 'JOD',
payment_method: 'cash',
assigned_at: null,
arrived_at: null,
started_at: null,
...overrides,
} as Trip;
}
@@ -49,6 +51,17 @@ describe('TripStateService', () => {
expect(s!.origin_lat).toBeCloseTo(31.95);
expect(s!.quoted_fare).toBe(3.75);
expect(s!.assigned_at).toBeNull();
expect(s!.city).toBe('amman');
});
it('يحمل طوابع الانتظار والبدء — يُحتسب منها الرسم بلا قراءة قاعدة (B2/B7)', async () => {
const arrived = new Date('2026-07-17T09:00:00.000Z');
const started = new Date('2026-07-17T09:07:00.000Z');
await state.save(makeTrip({ status: 'in_progress', arrived_at: arrived, started_at: started }));
const s = await state.get(TENANT, 'trip-1');
expect(s!.arrived_at?.toISOString()).toBe(arrived.toISOString());
expect(s!.started_at?.toISOString()).toBe(started.toISOString());
});
it('يرجع null لرحلة غير موجودة (فيرجع المُنادي للقاعدة)', async () => {
@@ -19,7 +19,12 @@ export interface TripState {
distance_km: number | null;
quoted_fare: number | null;
currency: string | null;
city: string;
assigned_at: Date | null;
/** بداية عدّ الانتظار — يُحتسب منه رسم الانتظار عند البدء (docs/17 — B2). */
arrived_at: Date | null;
/** بدء الرحلة فعلاً — منه يُقاس زمنها الحقيقي (docs/17 — B7). */
started_at: Date | null;
}
/** نتيجة محاولة القبول الذرّي. */
@@ -66,7 +71,10 @@ export class TripStateService {
distance_km: trip.distance_km == null ? '' : String(trip.distance_km),
quoted_fare: trip.quoted_fare == null ? '' : String(trip.quoted_fare),
currency: trip.currency ?? '',
city: trip.city ?? '',
assigned_at: trip.assigned_at ? trip.assigned_at.toISOString() : '',
arrived_at: trip.arrived_at ? trip.arrived_at.toISOString() : '',
started_at: trip.started_at ? trip.started_at.toISOString() : '',
})
.expire(k, ACTIVE_TTL_SEC)
.exec();
@@ -89,7 +97,10 @@ export class TripStateService {
distance_km: h.distance_km ? Number(h.distance_km) : null,
quoted_fare: h.quoted_fare ? Number(h.quoted_fare) : null,
currency: h.currency || null,
city: h.city || 'default',
assigned_at: h.assigned_at ? new Date(h.assigned_at) : null,
arrived_at: h.arrived_at ? new Date(h.arrived_at) : null,
started_at: h.started_at ? new Date(h.started_at) : null,
};
}
+151 -19
View File
@@ -11,6 +11,7 @@ import { Trip, TripStatus } from './entities/trip.entity';
import { TripEvent } from './entities/trip-event.entity';
import { MapsService } from '../maps/maps.service';
import { TariffService } from '../tariff/tariff.service';
import { TariffEngine } from '../tariff/tariff.engine';
import { MatchingService } from '../matching/matching.service';
import { DriversService } from '../drivers/drivers.service';
import { RealtimeGateway } from '../../realtime/realtime.gateway';
@@ -113,6 +114,10 @@ export class TripsService {
let currency: string | null = null;
let tariffId: string | null = null;
let tariffVersion: number | null = null;
// فصل تقديري يُعرض على السائق ليعرف دخله قبل القبول (docs/17 — B6).
let priceForDriver: number | null = null;
let commissionAmount: number | null = null;
let commissionRate: number | null = null;
try {
const q = await this.tariff.quote(tenantId, city, serviceClass, {
distanceKm: route.distanceKm,
@@ -122,6 +127,14 @@ export class TripsService {
currency = q.quote.currency;
tariffId = q.tariffId;
tariffVersion = q.version;
const def = (await this.tariff.getActive(tenantId, city, serviceClass))?.definition;
if (def) {
const split = TariffEngine.split(def, q.quote.total);
priceForDriver = split.forDriver;
commissionAmount = split.commission;
commissionRate = split.rate;
}
} catch {
this.logger.warn(`no tariff for ${city}/${serviceClass} — trip without quote`);
}
@@ -142,6 +155,10 @@ export class TripsService {
tariff_id: tariffId,
tariff_version: tariffVersion,
quoted_fare: quotedFare,
price_for_passenger: quotedFare,
price_for_driver: priceForDriver,
commission_amount: commissionAmount,
commission_rate: commissionRate,
currency: currency ?? undefined,
payment_method: (dto as any).payment_method ?? 'cash',
});
@@ -183,10 +200,17 @@ export class TripsService {
throw new BadRequestException('Trip is no longer available');
}
const trip = await this.getOr404(tenantId, tripId);
let trip = await this.getOr404(tenantId, tripId);
// مشغول الآن: يخرج من فهرس المتاحين فلا تُعرض عليه رحلة أخرى (H5).
await this.locations.setAvailability(tenantId, driver.id, driver.service_class, 'busy');
// مشوار وصول السائق للراكب — يُقاس من موقعه لحظة القبول (docs/17 — B3).
const pickupLeg = await this.measurePickupLeg(tenantId, driver.id, trip);
if (pickupLeg) {
await this.trips.update({ tenant_id: tenantId, id: tripId }, pickupLeg);
trip = await this.getOr404(tenantId, tripId);
}
await this.recordEvent(trip, 'searching', 'assigned', 'driver');
const driverUser = await this.users.findById(tenantId, driverUserId);
await this.notifyParties(tenantId, trip, 'assigned', driverUserId, {
@@ -215,27 +239,50 @@ export class TripsService {
const snapshot = await this.snapshot(tenantId, tripId);
this.assertTransition(snapshot.status, toStatus);
const now = new Date();
const patch: Partial<Trip> = { status: toStatus };
// كشف احتيال عند نقاط حسّاسة — الموقع الحيّ من Redis، لا صف السائق.
if (toStatus === 'driver_arrived' && snapshot.driver_id && snapshot.driver_user_id) {
const at = await this.locations.get(tenantId, snapshot.driver_id);
await this.fraud.checkArrivedProximity(
tenantId,
snapshot.driver_user_id,
at ? { lat: at.lat, lng: at.lng } : null,
{ lat: snapshot.origin_lat, lng: snapshot.origin_lng },
tripId,
);
// طوابع دقيقة لكل مرحلة (docs/17 — B7).
if (toStatus === 'driver_arriving') patch.driver_going_at = now;
if (toStatus === 'driver_arrived') {
patch.arrived_at = now; // بداية عدّ الانتظار (B2)
// كشف احتيال — الموقع الحيّ من Redis، لا صف السائق.
if (snapshot.driver_id && snapshot.driver_user_id) {
const at = await this.locations.get(tenantId, snapshot.driver_id);
await this.fraud.checkArrivedProximity(
tenantId,
snapshot.driver_user_id,
at ? { lat: at.lat, lng: at.lng } : null,
{ lat: snapshot.origin_lat, lng: snapshot.origin_lng },
tripId,
);
}
}
if (toStatus === 'in_progress') {
patch.started_at = now; // rideTimeStart — الراكب صعد فعلاً
// انتظر السائق من لحظة وصوله حتى الآن؛ أول 5 دقائق مجانية (B2).
if (snapshot.arrived_at) {
const waitedMin = (now.getTime() - snapshot.arrived_at.getTime()) / 60000;
const def = await this.tariffDef(tenantId, snapshot);
patch.waiting_min = Number(waitedMin.toFixed(1));
patch.waiting_charge = def
? TariffEngine.waitingCharge(def, waitedMin, now).charge
: 0;
}
}
if (toStatus === 'completed') {
patch.completed_at = new Date();
if (snapshot.quoted_fare != null) patch.final_fare = snapshot.quoted_fare;
patch.completed_at = now; // rideTimeFinish
Object.assign(patch, await this.settleFare(tenantId, tripId, snapshot));
if (snapshot.driver_user_id) {
await this.fraud.checkFastCompletion(
tenantId,
snapshot.driver_user_id,
snapshot.assigned_at,
// من لحظة **بدء** الرحلة لا إسنادها: مشوار الوصول والانتظار ليسا
// جزءاً من زمنها، وإلا بدت كل رحلة أطول مما هي (docs/17 — B7).
snapshot.started_at ?? snapshot.assigned_at,
snapshot.distance_km,
tripId,
);
@@ -255,20 +302,26 @@ export class TripsService {
await this.syncState(trip, snapshot, toStatus);
await this.recordEvent(trip, snapshot.status, toStatus, actor);
// تسوية المحفظة عند الدفع (payment_method === wallet)
// تسوية المحفظة عند الدفع (payment_method === wallet).
// الراكب يُخصم منه price_for_passenger، والسائق يُضاف له price_for_driver —
// الفرق هو عمولة المنصة (docs/17 — B6). كانا متساويين قبل فصل السعر، أي
// أن العمولة كانت تضيع.
if (toStatus === 'paid' && trip.payment_method === 'wallet' && trip.final_fare != null) {
const fare = Number(trip.final_fare);
const passengerPays = Number(trip.price_for_passenger ?? trip.final_fare);
const driverEarns = Number(trip.price_for_driver ?? trip.final_fare);
try {
await this.wallet.debit(tenantId, trip.rider_id, fare, 'trip_fare', trip.id);
if (snapshot.driver_user_id) {
await this.wallet.debit(tenantId, trip.rider_id, passengerPays, 'trip_fare', trip.id);
if (snapshot.driver_user_id && driverEarns > 0) {
await this.wallet.credit(
tenantId,
snapshot.driver_user_id,
fare,
driverEarns,
'trip_earning',
trip.id,
);
}
// TODO(I2): إيداع commission_amount في محفظة المنصة (مكافئ siroWallet).
// حتى تُبنى، العمولة محسوبة ومسجّلة على الرحلة لكنها لا تُقيَّد في محفظة.
} catch (e: any) {
this.logger.warn(`wallet settle failed: ${e?.message}`);
}
@@ -385,7 +438,84 @@ export class TripsService {
distance_km: trip.distance_km == null ? null : Number(trip.distance_km),
quoted_fare: trip.quoted_fare == null ? null : Number(trip.quoted_fare),
currency: trip.currency ?? null,
city: trip.city ?? 'default',
assigned_at: trip.assigned_at,
arrived_at: trip.arrived_at,
started_at: trip.started_at,
};
}
/**
* يقيس مشوار السائق حتى الراكب لحظة القبول (docs/17 — B3).
* يرجع null لو تعذّر — قياس المشوار لا يجوز أن يُفشل قبول الرحلة.
*/
private async measurePickupLeg(
tenantId: string,
driverId: string,
trip: Trip,
): Promise<Partial<Trip> | null> {
try {
const at = await this.locations.get(tenantId, driverId);
if (!at) return null;
const leg = await this.maps.route(
{ lat: at.lat, lng: at.lng },
{ lat: trip.origin_lat, lng: trip.origin_lng },
);
const def = await this.tariff
.getActive(tenantId, trip.city ?? 'default', trip.service_class)
.then((t) => t?.definition ?? null);
return {
pickup_distance_km: leg.distanceKm,
pickup_duration_min: leg.durationMin,
pickup_charge: def
? TariffEngine.pickupCharge(def, leg.distanceKm, leg.durationMin)
: 0,
};
} catch (e: any) {
this.logger.warn(`pickup leg measure failed: ${e?.message}`);
return null;
}
}
/** تعريف التعرفة الفعّالة للرحلة (من كاش Redis — docs/17 G5). */
private async tariffDef(tenantId: string, snapshot: TripState) {
const t = await this.tariff.getActive(tenantId, snapshot.city, snapshot.service_class);
return t?.definition ?? null;
}
/**
* الأجرة النهائية وفصلها (docs/17 — B2/B3/B6).
* الراكب يدفع: السعر المقفول + انتظاره الزائد + مشوار الوصول (إن فُعِّل).
* السائق يقبض: ذلك ناقص عمولة المنصة.
*/
private async settleFare(
tenantId: string,
tripId: string,
snapshot: TripState,
): Promise<Partial<Trip>> {
// الانتظار ومشوار الوصول حُسبا وخُزّنا سابقاً — نقرأهما من الصف.
const row = await this.trips.findOne({
where: { tenant_id: tenantId, id: tripId },
select: { waiting_charge: true, pickup_charge: true },
});
const base = snapshot.quoted_fare ?? 0;
const waiting = Number(row?.waiting_charge ?? 0);
const pickup = Number(row?.pickup_charge ?? 0);
const total = Number((base + waiting + pickup).toFixed(3));
const def = await this.tariffDef(tenantId, snapshot);
const split = def
? TariffEngine.split(def, total)
: { forPassenger: total, forDriver: total, commission: 0, rate: 0 };
return {
final_fare: split.forPassenger,
price_for_passenger: split.forPassenger,
price_for_driver: split.forDriver,
commission_amount: split.commission,
commission_rate: split.rate,
};
}
@@ -424,6 +554,8 @@ export class TripsService {
tripDistanceKm: trip.distance_km == null ? null : Number(trip.distance_km),
tripDurationMin: trip.duration_min == null ? null : Number(trip.duration_min),
quotedFare: trip.quoted_fare == null ? null : Number(trip.quoted_fare),
// ما يقبضه السائق فعلاً — لا أجرة الراكب (docs/17 — B6).
earnings: trip.price_for_driver == null ? null : Number(trip.price_for_driver),
currency: trip.currency ?? null,
serviceClass: trip.service_class,
paymentMethod: trip.payment_method,
+18 -15
View File
@@ -22,22 +22,25 @@
---
## المجموعة B — اكتمال نموذج الرحلة
## المجموعة B — اكتمال نموذج الرحلة — 🔵 الشريحة الأولى منفَّذة (المال والزمن)
> مقارنة بجدول `ride` في سيرو + قواعد التشغيل.
| # | البند | التفصيل |
|---|-------|---------|
| B1 | **حالة `started` وفصل الوصول عن البدء** | السائق وصل ≠ الرحلة بدأت. الراكب قد لا يرد/يلغي. نحتاج تمييزاً صريحاً. |
| B2 | **عدّاد انتظار 5 دقائق (قانون)** | يبدأ عند وصول السائق (بعد **تأكيد المسافة** من فلاتر). عند انتهائه: خيار للراكب — يبقى السائق منتظراً، أو **تعويض** فترة الانتظار. |
| B3 | **احتساب مشوار الوصول للراكب** | المسافة/الزمن من موقع السائق حتى الراكب تُحتسب بالتعرفة (دقائق + مسافة). |
| B4 | **نقاط توقف (stops)** | نقطتا توقف ضمن الرحلة (كما في سيرو/شير). |
| B5 | **حجز مسبق/جدولة** | جداول الرحلة (`date`/`time`/`endtime` في سيرو). |
| B6 | **فصل السعر** | `price_for_driver` مقابل `price_for_passenger` (العمولة) — **ناقص عندنا**. |
| B7 | **طوابع زمنية دقيقة** | `DriverIsGoingToPassenger` · `rideTimeStart` · `rideTimeFinish` (سيرو) — عندنا `assigned_at`/`completed_at` فقط. |
| B8 | **مطابقة الوجهة** | `is_destination_match` + خصم الراكب. |
| B9 | **تعديل التعرفة من لوحة الأدمن** | جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). |
| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). |
| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. الاستعلام عن التعرفة من Redis خط أول (G5). |
| # | البند | التفصيل | الحالة |
|---|-------|---------|--------|
| 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 | **احتساب مشوار الوصول للراكب** | يُقاس من موقع السائق **الحيّ لحظة القبول** حتى نقطة الالتقاط. **لا يُحتسب على الراكب ما لم يُفعِّله المالك** (`pickup_leg.charge`) — تفعيله يرفع كل أجرة، فهو قرار لا افتراض صامت. | ✅ `pickup_distance_km`/`_duration_min`/`_charge` |
| 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` + خصم الراكب. | ⏳ الشريحة التالية |
| B9 | **تعديل التعرفة من لوحة الأدمن** | جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). | ⏳ |
| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). | ⏳ |
| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. | ⏳ |
**الأجرة النهائية الآن** = السعر المقفول + رسم الانتظار الزائد + مشوار الوصول (إن فُعِّل)، ثم تُفصل: الراكب يدفع `price_for_passenger`، والسائق يقبض `price_for_driver`، والفرق عمولة.
**معلَّق على I2**: العمولة محسوبة ومسجّلة على الرحلة لكنها **لا تُقيَّد في محفظة المنصة** — لأن محفظة المنصة (مكافئ `siroWallet`) لم تُبنَ بعد.
---
@@ -187,7 +190,7 @@
2. ~~**I1** (سباق المحفظة)~~ ✅ **منفَّذة ومُثبَتة بتزامن حقيقي**.
3. ~~**G** (Redis خط أول: لغة/توكنات/tenant/تعرفة/تقييم/كاش الخرائط)~~ ✅ **منفَّذة**.
4. ~~**H** (المواقع: إيقاف الكتابة لكل نبضة + batching + tracks)~~ ✅ **منفَّذة** (عدا H6/H7 — تحليلات مؤجَّلة).
5. **B** (نموذج الرحلة: started/انتظار/فصل السعر/الطوابع/stops).
5. **B** — 🔵 الشريحة الأولى ✅ (الانتظار B2 · مشوار الوصول B3 · فصل السعر B6 · الطوابع B7). الباقي: B4 stops · B5 جدولة · B8 مطابقة الوجهة · B9 · B10 · B11.
6. **I** الباقي (المدفوعات: جداول لكل طريقة + OTP/بصمة/HMAC للسحب).
7. **C** (بيانات المركبة والسائق + ai_data).
8. **D** (تطبيع الهاتف + بصمة الجهاز + HMAC).