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:
co-authored by
Claude Opus 4.8
parent
7e24c63dd8
commit
f3fdc1bc20
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user