257 lines
9.5 KiB
TypeScript
257 lines
9.5 KiB
TypeScript
import { TariffDefinition, TariffWindow } from './entities/tariff.entity';
|
|
|
|
export interface QuoteInput {
|
|
distanceKm: number;
|
|
durationMin: number;
|
|
waitingMin?: number;
|
|
at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية)
|
|
geofenceMultiplier?: number;
|
|
/** وزن الحمولة بالكيلوغرام — للشحنات والطرود فقط (L3). */
|
|
weightKg?: number;
|
|
}
|
|
|
|
export interface QuoteBreakdown {
|
|
window: string;
|
|
flag: number;
|
|
distance: number;
|
|
time: number;
|
|
waiting: number;
|
|
weight: number;
|
|
bookingFee: number;
|
|
subtotal: number;
|
|
surgeMultiplier: number;
|
|
total: number;
|
|
currency: string;
|
|
}
|
|
|
|
/**
|
|
* محرك التعرفة النقي (قابل للاختبار بوحدات) — نفس المدخل = نفس المخرج.
|
|
* يدعم أوضاع docs/04: time_and_distance و time_or_distance (عتبة سرعة).
|
|
*/
|
|
export class TariffEngine {
|
|
static quote(def: TariffDefinition, input: QuoteInput): QuoteBreakdown {
|
|
const at = input.at ?? new Date();
|
|
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);
|
|
|
|
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);
|
|
|
|
// --- L3: وزن الحمولة (شحنات على خطوط ثابتة) ---
|
|
const weightKg = Math.max(0, input.weightKg ?? 0);
|
|
let weightCharge = 0;
|
|
if (def.weight && weightKg > def.weight.free_kg) {
|
|
weightCharge = (weightKg - def.weight.free_kg) * def.weight.per_kg_above;
|
|
}
|
|
|
|
const multiplier =
|
|
(def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1) *
|
|
(input.geofenceMultiplier ?? 1.0);
|
|
const fixedTotal = TariffEngine.round(
|
|
(fixed + waitingOnly + weightCharge) * multiplier,
|
|
def.rounding,
|
|
);
|
|
return {
|
|
window: win.name,
|
|
flag: 0,
|
|
distance: 0,
|
|
time: 0,
|
|
waiting: TariffEngine.n(waitingOnly),
|
|
weight: TariffEngine.n(weightCharge),
|
|
bookingFee: 0,
|
|
subtotal: TariffEngine.n(fixed + waitingOnly + weightCharge),
|
|
surgeMultiplier: multiplier,
|
|
total: fixedTotal,
|
|
currency: def.currency,
|
|
};
|
|
}
|
|
|
|
if (def.mode === 'time_or_distance') {
|
|
// العداد المنظَّم: تحت العتبة يُحسب بالدقيقة (زحمة)، فوقها بالكيلومتر.
|
|
const avgSpeed = durationMin > 0 ? (distanceKm / durationMin) * 60 : 999;
|
|
const threshold = def.speed_threshold_kmh ?? 18;
|
|
if (avgSpeed < threshold) {
|
|
timeCharge = durationMin * win.per_min;
|
|
} else {
|
|
distanceCharge = distanceKm * win.per_km;
|
|
}
|
|
} else {
|
|
// time_and_distance — العدّاد المعتاد: فتحة + مسافة + زمن.
|
|
distanceCharge = distanceKm * win.per_km;
|
|
timeCharge = durationMin * win.per_min;
|
|
}
|
|
|
|
const waitingCharge = waitingMin * (win.per_min_waiting ?? 0);
|
|
const bookingFee = def.booking_fee ?? 0;
|
|
|
|
// --- L3: وزن الحمولة ---
|
|
const weightKg = Math.max(0, input.weightKg ?? 0);
|
|
let weightCharge = 0;
|
|
if (def.weight && weightKg > def.weight.free_kg) {
|
|
weightCharge = (weightKg - def.weight.free_kg) * def.weight.per_kg_above;
|
|
}
|
|
|
|
let subtotal =
|
|
win.flag + distanceCharge + timeCharge + waitingCharge + weightCharge + bookingFee;
|
|
|
|
const surgeMultiplier =
|
|
def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1;
|
|
|
|
const geofenceMultiplier = input.geofenceMultiplier ?? 1.0;
|
|
const finalMultiplier = surgeMultiplier * geofenceMultiplier;
|
|
|
|
subtotal *= finalMultiplier;
|
|
|
|
let total = Math.max(def.min_fare ?? 0, subtotal);
|
|
total = TariffEngine.round(total, def.rounding);
|
|
|
|
return {
|
|
window: win.name,
|
|
flag: win.flag,
|
|
distance: TariffEngine.n(distanceCharge),
|
|
time: TariffEngine.n(timeCharge),
|
|
waiting: TariffEngine.n(waitingCharge),
|
|
weight: TariffEngine.n(weightCharge),
|
|
bookingFee,
|
|
subtotal: TariffEngine.n(subtotal),
|
|
surgeMultiplier,
|
|
total: TariffEngine.n(total),
|
|
currency: def.currency,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* رسم الانتظار بعد وصول السائق (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, def.timezone);
|
|
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 {
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* حساب العمولة (docs/18).
|
|
*
|
|
* **العمولة لا تُقتطع من أجرة السائق** — السائق يقبض الأجرة كاملة من الراكب،
|
|
* والعمولة تُخصم من رصيده التشغيلي المدفوع سلفاً. هذه الدالة تحسب المبلغ
|
|
* المستحق فقط؛ من يخصمه هو `DriverCreditService`.
|
|
*
|
|
* لذلك **لا سقف** يمنع تجاوز العمولة للأجرة هنا: الخصم يقع على رصيد منفصل
|
|
* يجوز أن يصير سالباً، لا على دخل السائق.
|
|
*/
|
|
static commission(
|
|
def: TariffDefinition,
|
|
passengerTotal: number,
|
|
): { amount: number; rate: number } {
|
|
const total = Math.max(0, passengerTotal ?? 0);
|
|
const c = def.commission;
|
|
if (!c) return { amount: 0, rate: 0 };
|
|
|
|
const percent = c.percent ?? 0;
|
|
let amount = total * (percent / 100) + (c.flat ?? 0);
|
|
if (c.min != null && amount < c.min) amount = c.min;
|
|
return { amount: TariffEngine.n(amount), rate: percent };
|
|
}
|
|
|
|
/**
|
|
* دقائق اليوم بالتوقيت **المحلي للتعرفة** لا بـ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);
|
|
const from = fromH * 60 + fromM;
|
|
const to = toH * 60 + toM;
|
|
// نافذة تعبر منتصف الليل (مثل 22:00 → 06:00)
|
|
if (from <= to ? mins >= from && mins < to : mins >= from || mins < to) {
|
|
return w;
|
|
}
|
|
}
|
|
return windows[0];
|
|
}
|
|
|
|
private static round(
|
|
value: number,
|
|
rounding?: { increment: number; mode?: 'nearest' | 'up' | 'down' },
|
|
): number {
|
|
if (!rounding || !rounding.increment) return TariffEngine.n(value);
|
|
const q = value / rounding.increment;
|
|
const r =
|
|
rounding.mode === 'up'
|
|
? Math.ceil(q)
|
|
: rounding.mode === 'down'
|
|
? Math.floor(q)
|
|
: Math.round(q);
|
|
return TariffEngine.n(r * rounding.increment);
|
|
}
|
|
|
|
private static n(v: number): number {
|
|
return Number(v.toFixed(3));
|
|
}
|
|
}
|