import { TariffDefinition, TariffWindow } from './entities/tariff.entity'; export interface QuoteInput { distanceKm: number; durationMin: number; waitingMin?: number; at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية) } export interface QuoteBreakdown { window: string; flag: number; distance: number; time: number; waiting: 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); 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 === '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 (و fixed_quote يُحسب مرة ويثبّت) distanceCharge = distanceKm * win.per_km; timeCharge = durationMin * win.per_min; } const waitingCharge = waitingMin * (win.per_min_waiting ?? 0); const bookingFee = def.booking_fee ?? 0; let subtotal = win.flag + distanceCharge + timeCharge + waitingCharge + bookingFee; const surgeMultiplier = def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1; let total = subtotal * surgeMultiplier; if (def.min_fare && total < def.min_fare) total = def.min_fare; 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), 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); 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) { 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)); } }