P1: drivers + trips(★state machine) + matching(Redis GEO) + tariff engine + maps + realtime + seed

- drivers: apply/approve/status/location (+Redis GEO presence)
- trips: request→accept→status transitions + trip_events audit + offers via socket
- matching: Redis GEO nearby drivers
- tariff: JSON-rule engine (time_or_distance/window/rounding) + quote endpoint
- maps: straight-line routing seam (انطلق later)
- realtime: Socket.IO gateway (JWT auth, tenant rooms)
- seed: demo tenant siro + default tariff on boot
- migration InitP1 (drivers/tariffs/trips/trip_events)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 16:41:58 +03:00
co-authored by Claude Opus 4.8
parent 4397b07d2f
commit 00870a8cc3
26 changed files with 1358 additions and 1 deletions
+113
View File
@@ -0,0 +1,113 @@
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,
};
}
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));
}
}