feat: implement rewards module with referral/coupon systems, update tariff seeding, and add trip distance service for improved billing accuracy.
This commit is contained in:
@@ -28,7 +28,7 @@ export interface QuoteBreakdown {
|
||||
export class TariffEngine {
|
||||
static quote(def: TariffDefinition, input: QuoteInput): QuoteBreakdown {
|
||||
const at = input.at ?? new Date();
|
||||
const win = TariffEngine.pickWindow(def.windows, at);
|
||||
const win = TariffEngine.pickWindow(def.windows, at, def.timezone);
|
||||
const distanceKm = Math.max(0, input.distanceKm);
|
||||
const durationMin = Math.max(0, input.durationMin);
|
||||
const waitingMin = Math.max(0, input.waitingMin ?? 0);
|
||||
@@ -36,6 +36,36 @@ export class TariffEngine {
|
||||
let distanceCharge = 0;
|
||||
let timeCharge = 0;
|
||||
|
||||
if (def.mode === 'fixed_quote') {
|
||||
// سعر ثابت متفَق عليه (خط ثابت / وجهة بسعر معلن): لا مسافة ولا زمن.
|
||||
// كان يسقط سابقاً إلى الفرع المتري فيُحسب بالعدّاد رغم اسمه — أي أن
|
||||
// «السعر الثابت» لم يكن ثابتاً أبداً.
|
||||
const fixed = Math.max(0, def.fixed_fare ?? 0);
|
||||
const waitingOnly = waitingMin * (win.per_min_waiting ?? 0);
|
||||
// الانتظار وحده يُضاف: بقاء الراكب ربع ساعة تكلفة حقيقية على السائق
|
||||
// مهما كان الخط ثابتاً. ولا حدّ أدنى هنا — السعر المعلن **هو** الاتفاق،
|
||||
// ورفعه بحدّ أدنى يعني إعلان سعر وتحصيل غيره.
|
||||
const multiplier =
|
||||
(def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1) *
|
||||
(input.geofenceMultiplier ?? 1.0);
|
||||
const fixedTotal = TariffEngine.round(
|
||||
(fixed + waitingOnly) * multiplier,
|
||||
def.rounding,
|
||||
);
|
||||
return {
|
||||
window: win.name,
|
||||
flag: 0,
|
||||
distance: 0,
|
||||
time: 0,
|
||||
waiting: TariffEngine.n(waitingOnly),
|
||||
bookingFee: 0,
|
||||
subtotal: TariffEngine.n(fixed + waitingOnly),
|
||||
surgeMultiplier: multiplier,
|
||||
total: fixedTotal,
|
||||
currency: def.currency,
|
||||
};
|
||||
}
|
||||
|
||||
if (def.mode === 'time_or_distance') {
|
||||
// العداد المنظَّم: تحت العتبة يُحسب بالدقيقة (زحمة)، فوقها بالكيلومتر.
|
||||
const avgSpeed = durationMin > 0 ? (distanceKm / durationMin) * 60 : 999;
|
||||
@@ -46,7 +76,7 @@ export class TariffEngine {
|
||||
distanceCharge = distanceKm * win.per_km;
|
||||
}
|
||||
} else {
|
||||
// time_and_distance (و fixed_quote يُحسب مرة ويثبّت)
|
||||
// time_and_distance — العدّاد المعتاد: فتحة + مسافة + زمن.
|
||||
distanceCharge = distanceKm * win.per_km;
|
||||
timeCharge = durationMin * win.per_min;
|
||||
}
|
||||
@@ -91,7 +121,7 @@ export class TariffEngine {
|
||||
waitedMin: number,
|
||||
at: Date = new Date(),
|
||||
): { freeMin: number; billableMin: number; charge: number } {
|
||||
const win = TariffEngine.pickWindow(def.windows, at);
|
||||
const win = TariffEngine.pickWindow(def.windows, at, def.timezone);
|
||||
const freeMin = def.free_waiting_min ?? 5;
|
||||
const billableMin = Math.max(0, (waitedMin ?? 0) - freeMin);
|
||||
return {
|
||||
@@ -114,7 +144,7 @@ export class TariffEngine {
|
||||
durationMin: number,
|
||||
at: Date = new Date(),
|
||||
): number {
|
||||
const win = TariffEngine.pickWindow(def.windows, at);
|
||||
const win = TariffEngine.pickWindow(def.windows, at, def.timezone);
|
||||
const charge =
|
||||
Math.max(0, distanceKm ?? 0) * win.per_km + Math.max(0, durationMin ?? 0) * win.per_min;
|
||||
return TariffEngine.n(charge);
|
||||
@@ -144,8 +174,39 @@ export class TariffEngine {
|
||||
return { amount: TariffEngine.n(amount), rate: percent };
|
||||
}
|
||||
|
||||
private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow {
|
||||
const mins = at.getUTCHours() * 60 + at.getUTCMinutes();
|
||||
/**
|
||||
* دقائق اليوم بالتوقيت **المحلي للتعرفة** لا بـUTC ولا بتوقيت الخادم.
|
||||
*
|
||||
* `Intl` هو من يحمل جدول التوقيت الصيفي: مصر أعادت العمل به 2023، فأي
|
||||
* إزاحة ثابتة محفورة في الكود كانت ستنحرف ساعةً كاملة نصف السنة —
|
||||
* وساعة كاملة تعني تسعير ذروة على رحلات عادية.
|
||||
*/
|
||||
private static localMinutes(at: Date, timezone?: string): number {
|
||||
const tz = timezone ?? 'Asia/Amman';
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(at);
|
||||
const h = Number(parts.find((p) => p.type === 'hour')?.value);
|
||||
const m = Number(parts.find((p) => p.type === 'minute')?.value);
|
||||
// 24:00 بدل 00:00 يخرج من بعض الإصدارات — يُطبَّع لا يُمرَّر.
|
||||
if (Number.isFinite(h) && Number.isFinite(m)) return (h % 24) * 60 + m;
|
||||
} catch {
|
||||
// منطقة زمنية مكتوبة خطأً في إعداد التعرفة: نسقط لتوقيت الخادم بدل
|
||||
// أن نرمي — تسعير بنافذة تقريبية أهون من رحلة بلا سعر إطلاقاً.
|
||||
}
|
||||
return at.getHours() * 60 + at.getMinutes();
|
||||
}
|
||||
|
||||
private static pickWindow(
|
||||
windows: TariffWindow[],
|
||||
at: Date,
|
||||
timezone?: string,
|
||||
): TariffWindow {
|
||||
const mins = TariffEngine.localMinutes(at, timezone);
|
||||
for (const w of windows) {
|
||||
const [fromH, fromM] = w.from.split(':').map(Number);
|
||||
const [toH, toM] = w.to.split(':').map(Number);
|
||||
|
||||
Reference in New Issue
Block a user