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;
|
booking_fee?: number;
|
||||||
min_fare?: number;
|
min_fare?: number;
|
||||||
surge?: { enabled: boolean; multiplier?: number; max?: 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 {
|
private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow {
|
||||||
const mins = at.getUTCHours() * 60 + at.getUTCMinutes();
|
const mins = at.getUTCHours() * 60 + at.getUTCMinutes();
|
||||||
for (const w of windows) {
|
for (const w of windows) {
|
||||||
|
|||||||
@@ -79,6 +79,36 @@ export class Trip {
|
|||||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||||
final_fare: number | null;
|
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 })
|
@Column({ nullable: true })
|
||||||
currency: string;
|
currency: string;
|
||||||
|
|
||||||
@@ -91,12 +121,26 @@ export class Trip {
|
|||||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||||
cancel_fee: number | null;
|
cancel_fee: number | null;
|
||||||
|
|
||||||
|
// ---- طوابع دقيقة (docs/17 — B7؛ مقابلها في سيرو بين قوسين) ----
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
requested_at: Date;
|
requested_at: Date;
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
assigned_at: Date | null;
|
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 })
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
completed_at: Date | null;
|
completed_at: Date | null;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function makeTrip(overrides: Partial<Trip> = {}): Trip {
|
|||||||
currency: 'JOD',
|
currency: 'JOD',
|
||||||
payment_method: 'cash',
|
payment_method: 'cash',
|
||||||
assigned_at: null,
|
assigned_at: null,
|
||||||
|
arrived_at: null,
|
||||||
|
started_at: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
} as Trip;
|
} as Trip;
|
||||||
}
|
}
|
||||||
@@ -49,6 +51,17 @@ describe('TripStateService', () => {
|
|||||||
expect(s!.origin_lat).toBeCloseTo(31.95);
|
expect(s!.origin_lat).toBeCloseTo(31.95);
|
||||||
expect(s!.quoted_fare).toBe(3.75);
|
expect(s!.quoted_fare).toBe(3.75);
|
||||||
expect(s!.assigned_at).toBeNull();
|
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 () => {
|
it('يرجع null لرحلة غير موجودة (فيرجع المُنادي للقاعدة)', async () => {
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export interface TripState {
|
|||||||
distance_km: number | null;
|
distance_km: number | null;
|
||||||
quoted_fare: number | null;
|
quoted_fare: number | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
|
city: string;
|
||||||
assigned_at: Date | null;
|
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),
|
distance_km: trip.distance_km == null ? '' : String(trip.distance_km),
|
||||||
quoted_fare: trip.quoted_fare == null ? '' : String(trip.quoted_fare),
|
quoted_fare: trip.quoted_fare == null ? '' : String(trip.quoted_fare),
|
||||||
currency: trip.currency ?? '',
|
currency: trip.currency ?? '',
|
||||||
|
city: trip.city ?? '',
|
||||||
assigned_at: trip.assigned_at ? trip.assigned_at.toISOString() : '',
|
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)
|
.expire(k, ACTIVE_TTL_SEC)
|
||||||
.exec();
|
.exec();
|
||||||
@@ -89,7 +97,10 @@ export class TripStateService {
|
|||||||
distance_km: h.distance_km ? Number(h.distance_km) : null,
|
distance_km: h.distance_km ? Number(h.distance_km) : null,
|
||||||
quoted_fare: h.quoted_fare ? Number(h.quoted_fare) : null,
|
quoted_fare: h.quoted_fare ? Number(h.quoted_fare) : null,
|
||||||
currency: h.currency || null,
|
currency: h.currency || null,
|
||||||
|
city: h.city || 'default',
|
||||||
assigned_at: h.assigned_at ? new Date(h.assigned_at) : null,
|
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 { TripEvent } from './entities/trip-event.entity';
|
||||||
import { MapsService } from '../maps/maps.service';
|
import { MapsService } from '../maps/maps.service';
|
||||||
import { TariffService } from '../tariff/tariff.service';
|
import { TariffService } from '../tariff/tariff.service';
|
||||||
|
import { TariffEngine } from '../tariff/tariff.engine';
|
||||||
import { MatchingService } from '../matching/matching.service';
|
import { MatchingService } from '../matching/matching.service';
|
||||||
import { DriversService } from '../drivers/drivers.service';
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
import { RealtimeGateway } from '../../realtime/realtime.gateway';
|
import { RealtimeGateway } from '../../realtime/realtime.gateway';
|
||||||
@@ -113,6 +114,10 @@ export class TripsService {
|
|||||||
let currency: string | null = null;
|
let currency: string | null = null;
|
||||||
let tariffId: string | null = null;
|
let tariffId: string | null = null;
|
||||||
let tariffVersion: number | 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 {
|
try {
|
||||||
const q = await this.tariff.quote(tenantId, city, serviceClass, {
|
const q = await this.tariff.quote(tenantId, city, serviceClass, {
|
||||||
distanceKm: route.distanceKm,
|
distanceKm: route.distanceKm,
|
||||||
@@ -122,6 +127,14 @@ export class TripsService {
|
|||||||
currency = q.quote.currency;
|
currency = q.quote.currency;
|
||||||
tariffId = q.tariffId;
|
tariffId = q.tariffId;
|
||||||
tariffVersion = q.version;
|
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 {
|
} catch {
|
||||||
this.logger.warn(`no tariff for ${city}/${serviceClass} — trip without quote`);
|
this.logger.warn(`no tariff for ${city}/${serviceClass} — trip without quote`);
|
||||||
}
|
}
|
||||||
@@ -142,6 +155,10 @@ export class TripsService {
|
|||||||
tariff_id: tariffId,
|
tariff_id: tariffId,
|
||||||
tariff_version: tariffVersion,
|
tariff_version: tariffVersion,
|
||||||
quoted_fare: quotedFare,
|
quoted_fare: quotedFare,
|
||||||
|
price_for_passenger: quotedFare,
|
||||||
|
price_for_driver: priceForDriver,
|
||||||
|
commission_amount: commissionAmount,
|
||||||
|
commission_rate: commissionRate,
|
||||||
currency: currency ?? undefined,
|
currency: currency ?? undefined,
|
||||||
payment_method: (dto as any).payment_method ?? 'cash',
|
payment_method: (dto as any).payment_method ?? 'cash',
|
||||||
});
|
});
|
||||||
@@ -183,10 +200,17 @@ export class TripsService {
|
|||||||
throw new BadRequestException('Trip is no longer available');
|
throw new BadRequestException('Trip is no longer available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const trip = await this.getOr404(tenantId, tripId);
|
let trip = await this.getOr404(tenantId, tripId);
|
||||||
// مشغول الآن: يخرج من فهرس المتاحين فلا تُعرض عليه رحلة أخرى (H5).
|
// مشغول الآن: يخرج من فهرس المتاحين فلا تُعرض عليه رحلة أخرى (H5).
|
||||||
await this.locations.setAvailability(tenantId, driver.id, driver.service_class, 'busy');
|
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');
|
await this.recordEvent(trip, 'searching', 'assigned', 'driver');
|
||||||
const driverUser = await this.users.findById(tenantId, driverUserId);
|
const driverUser = await this.users.findById(tenantId, driverUserId);
|
||||||
await this.notifyParties(tenantId, trip, 'assigned', driverUserId, {
|
await this.notifyParties(tenantId, trip, 'assigned', driverUserId, {
|
||||||
@@ -215,27 +239,50 @@ export class TripsService {
|
|||||||
const snapshot = await this.snapshot(tenantId, tripId);
|
const snapshot = await this.snapshot(tenantId, tripId);
|
||||||
this.assertTransition(snapshot.status, toStatus);
|
this.assertTransition(snapshot.status, toStatus);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
const patch: Partial<Trip> = { status: toStatus };
|
const patch: Partial<Trip> = { status: toStatus };
|
||||||
|
|
||||||
// كشف احتيال عند نقاط حسّاسة — الموقع الحيّ من Redis، لا صف السائق.
|
// طوابع دقيقة لكل مرحلة (docs/17 — B7).
|
||||||
if (toStatus === 'driver_arrived' && snapshot.driver_id && snapshot.driver_user_id) {
|
if (toStatus === 'driver_arriving') patch.driver_going_at = now;
|
||||||
const at = await this.locations.get(tenantId, snapshot.driver_id);
|
|
||||||
await this.fraud.checkArrivedProximity(
|
if (toStatus === 'driver_arrived') {
|
||||||
tenantId,
|
patch.arrived_at = now; // بداية عدّ الانتظار (B2)
|
||||||
snapshot.driver_user_id,
|
// كشف احتيال — الموقع الحيّ من Redis، لا صف السائق.
|
||||||
at ? { lat: at.lat, lng: at.lng } : null,
|
if (snapshot.driver_id && snapshot.driver_user_id) {
|
||||||
{ lat: snapshot.origin_lat, lng: snapshot.origin_lng },
|
const at = await this.locations.get(tenantId, snapshot.driver_id);
|
||||||
tripId,
|
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') {
|
if (toStatus === 'completed') {
|
||||||
patch.completed_at = new Date();
|
patch.completed_at = now; // rideTimeFinish
|
||||||
if (snapshot.quoted_fare != null) patch.final_fare = snapshot.quoted_fare;
|
Object.assign(patch, await this.settleFare(tenantId, tripId, snapshot));
|
||||||
if (snapshot.driver_user_id) {
|
if (snapshot.driver_user_id) {
|
||||||
await this.fraud.checkFastCompletion(
|
await this.fraud.checkFastCompletion(
|
||||||
tenantId,
|
tenantId,
|
||||||
snapshot.driver_user_id,
|
snapshot.driver_user_id,
|
||||||
snapshot.assigned_at,
|
// من لحظة **بدء** الرحلة لا إسنادها: مشوار الوصول والانتظار ليسا
|
||||||
|
// جزءاً من زمنها، وإلا بدت كل رحلة أطول مما هي (docs/17 — B7).
|
||||||
|
snapshot.started_at ?? snapshot.assigned_at,
|
||||||
snapshot.distance_km,
|
snapshot.distance_km,
|
||||||
tripId,
|
tripId,
|
||||||
);
|
);
|
||||||
@@ -255,20 +302,26 @@ export class TripsService {
|
|||||||
await this.syncState(trip, snapshot, toStatus);
|
await this.syncState(trip, snapshot, toStatus);
|
||||||
await this.recordEvent(trip, snapshot.status, toStatus, actor);
|
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) {
|
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 {
|
try {
|
||||||
await this.wallet.debit(tenantId, trip.rider_id, fare, 'trip_fare', trip.id);
|
await this.wallet.debit(tenantId, trip.rider_id, passengerPays, 'trip_fare', trip.id);
|
||||||
if (snapshot.driver_user_id) {
|
if (snapshot.driver_user_id && driverEarns > 0) {
|
||||||
await this.wallet.credit(
|
await this.wallet.credit(
|
||||||
tenantId,
|
tenantId,
|
||||||
snapshot.driver_user_id,
|
snapshot.driver_user_id,
|
||||||
fare,
|
driverEarns,
|
||||||
'trip_earning',
|
'trip_earning',
|
||||||
trip.id,
|
trip.id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// TODO(I2): إيداع commission_amount في محفظة المنصة (مكافئ siroWallet).
|
||||||
|
// حتى تُبنى، العمولة محسوبة ومسجّلة على الرحلة لكنها لا تُقيَّد في محفظة.
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logger.warn(`wallet settle failed: ${e?.message}`);
|
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),
|
distance_km: trip.distance_km == null ? null : Number(trip.distance_km),
|
||||||
quoted_fare: trip.quoted_fare == null ? null : Number(trip.quoted_fare),
|
quoted_fare: trip.quoted_fare == null ? null : Number(trip.quoted_fare),
|
||||||
currency: trip.currency ?? null,
|
currency: trip.currency ?? null,
|
||||||
|
city: trip.city ?? 'default',
|
||||||
assigned_at: trip.assigned_at,
|
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),
|
tripDistanceKm: trip.distance_km == null ? null : Number(trip.distance_km),
|
||||||
tripDurationMin: trip.duration_min == null ? null : Number(trip.duration_min),
|
tripDurationMin: trip.duration_min == null ? null : Number(trip.duration_min),
|
||||||
quotedFare: trip.quoted_fare == null ? null : Number(trip.quoted_fare),
|
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,
|
currency: trip.currency ?? null,
|
||||||
serviceClass: trip.service_class,
|
serviceClass: trip.service_class,
|
||||||
paymentMethod: trip.payment_method,
|
paymentMethod: trip.payment_method,
|
||||||
|
|||||||
+18
-15
@@ -22,22 +22,25 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## المجموعة B — اكتمال نموذج الرحلة
|
## المجموعة B — اكتمال نموذج الرحلة — 🔵 الشريحة الأولى منفَّذة (المال والزمن)
|
||||||
> مقارنة بجدول `ride` في سيرو + قواعد التشغيل.
|
> مقارنة بجدول `ride` في سيرو + قواعد التشغيل.
|
||||||
|
|
||||||
| # | البند | التفصيل |
|
| # | البند | التفصيل | الحالة |
|
||||||
|---|-------|---------|
|
|---|-------|---------|--------|
|
||||||
| B1 | **حالة `started` وفصل الوصول عن البدء** | السائق وصل ≠ الرحلة بدأت. الراكب قد لا يرد/يلغي. نحتاج تمييزاً صريحاً. |
|
| B1 | **حالة `started` وفصل الوصول عن البدء** | — | ✅ **كان منفَّذاً أصلاً**: `driver_arrived` و`in_progress` حالتان منفصلتان في آلة الحالة منذ P1. الناقص كان الطوابع والانتظار (B2/B7) لا الحالة نفسها. |
|
||||||
| B2 | **عدّاد انتظار 5 دقائق (قانون)** | يبدأ عند وصول السائق (بعد **تأكيد المسافة** من فلاتر). عند انتهائه: خيار للراكب — يبقى السائق منتظراً، أو **تعويض** فترة الانتظار. |
|
| B2 | **عدّاد انتظار 5 دقائق (قانون)** | يبدأ عند `driver_arrived`، ويُحتسب عند `in_progress`. الدقائق المجانية `free_waiting_min` (5 افتراضاً) قابلة للضبط لكل تعرفة، والزائد بسعر `per_min_waiting` للنافذة الفعّالة. | ✅ `waiting_min` + `waiting_charge` |
|
||||||
| B3 | **احتساب مشوار الوصول للراكب** | المسافة/الزمن من موقع السائق حتى الراكب تُحتسب بالتعرفة (دقائق + مسافة). |
|
| B3 | **احتساب مشوار الوصول للراكب** | يُقاس من موقع السائق **الحيّ لحظة القبول** حتى نقطة الالتقاط. **لا يُحتسب على الراكب ما لم يُفعِّله المالك** (`pickup_leg.charge`) — تفعيله يرفع كل أجرة، فهو قرار لا افتراض صامت. | ✅ `pickup_distance_km`/`_duration_min`/`_charge` |
|
||||||
| B4 | **نقاط توقف (stops)** | نقطتا توقف ضمن الرحلة (كما في سيرو/شير). |
|
| B6 | **فصل السعر** | `price_for_passenger` · `price_for_driver` · `commission_amount` · `commission_rate`. العمولة تُعرَّف في التعرفة: `commission: { percent, flat, min }`، ولا تتجاوز الأجرة أبداً. | ✅ + **تسوية المحفظة صُحّحت**: كانت تُضيف للسائق كامل أجرة الراكب — أي أن العمولة كانت تضيع |
|
||||||
| B5 | **حجز مسبق/جدولة** | جداول الرحلة (`date`/`time`/`endtime` في سيرو). |
|
| B7 | **طوابع زمنية دقيقة** | `driver_going_at` (`DriverIsGoingToPassenger`) · `arrived_at` · `started_at` (`rideTimeStart`) · `completed_at` (`rideTimeFinish`). | ✅ + كشف «الإنهاء السريع» صار يقيس من **بدء** الرحلة لا إسنادها |
|
||||||
| B6 | **فصل السعر** | `price_for_driver` مقابل `price_for_passenger` (العمولة) — **ناقص عندنا**. |
|
| B4 | **نقاط توقف (stops)** | نقطتا توقف ضمن الرحلة (كما في سيرو/شير). | ⏳ الشريحة التالية |
|
||||||
| B7 | **طوابع زمنية دقيقة** | `DriverIsGoingToPassenger` · `rideTimeStart` · `rideTimeFinish` (سيرو) — عندنا `assigned_at`/`completed_at` فقط. |
|
| B5 | **حجز مسبق/جدولة** | جداول الرحلة (`date`/`time`/`endtime` في سيرو). | ⏳ الشريحة التالية |
|
||||||
| B8 | **مطابقة الوجهة** | `is_destination_match` + خصم الراكب. |
|
| B8 | **مطابقة الوجهة** | `is_destination_match` + خصم الراكب. | ⏳ الشريحة التالية |
|
||||||
| B9 | **تعديل التعرفة من لوحة الأدمن** | جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). |
|
| B9 | **تعديل التعرفة من لوحة الأدمن** | جداول تعرفة قابلة للتحرير (موجودة كـ jsonb — نحتاج واجهة/نقاط CRUD). | ⏳ |
|
||||||
| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). |
|
| B10 | **كتالوج أنواع الرحلات العالمي** | `ride_types` فكرته سليمة (الأدمن/المستأجر يضيف أنواعه). المطلوب: كتالوج جاهز بما هو شائع عالمياً كخيارات جاهزة للاختيار (عندنا 6 فقط الآن). | ⏳ |
|
||||||
| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. الاستعلام عن التعرفة من Redis خط أول (G5). |
|
| B11 | **تعرفة بالوزن** | بُعد تسعير إضافي بالوزن (للشحن/التوصيل) بجانب المسافة والزمن. | ⏳ |
|
||||||
|
|
||||||
|
**الأجرة النهائية الآن** = السعر المقفول + رسم الانتظار الزائد + مشوار الوصول (إن فُعِّل)، ثم تُفصل: الراكب يدفع `price_for_passenger`، والسائق يقبض `price_for_driver`، والفرق عمولة.
|
||||||
|
**معلَّق على I2**: العمولة محسوبة ومسجّلة على الرحلة لكنها **لا تُقيَّد في محفظة المنصة** — لأن محفظة المنصة (مكافئ `siroWallet`) لم تُبنَ بعد.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -187,7 +190,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** (نموذج الرحلة: started/انتظار/فصل السعر/الطوابع/stops).
|
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