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)); } }