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:
Hamza-Ayed
2026-07-19 00:20:37 +03:00
parent 22d18f32fe
commit 92f1eeb48c
39 changed files with 3612 additions and 208 deletions
@@ -0,0 +1,197 @@
import { TariffDefinition } from './entities/tariff.entity';
/**
* تعرفة الانطلاق الافتراضية لكل بلد — **مصدر الحقيقة الوحيد**.
*
* يستهلكها اثنان: `SeedService` (فيأخذها كل مستأجر جديد تلقائياً) ومولّد
* الـSQL (`scripts/generate-tariff-sql.ts`) لقواعد قائمة. نسختان من هذه
* الأرقام كانتا ستتباعدان بصمت، والتسعير أسوأ مكان يقع فيه هذا.
*
* هذه تعرفة **بداية** فقط؛ يُفترض أن يحدّثها كرون استخبارات الأسعار بنسخة
* أعلى (`version + 1`) لا أن يكتب فوقها.
*
* قرارات تحكم الملف (وسبب كلٍّ منها):
*
* 1. **`city = 'default'`** لا اسم البلد — كل الكود يبحث بهذه القيمة
* (`trips.service`: `dto.city ?? 'default'`). النسخة السابقة كتبت
* `'jordan'/'egypt'/'syria'` فما طابقت شيئاً: لا تعرفة ← الراكب بلا
* سعر ← `settleFare` تأخذ `quoted_fare ?? 0` ← **رحلة مجانية وعمولة
* صفر**، بسطر تحذير واحد في اللوج. البلد من `country_pack` للمستأجر.
*
* 2. **النوافذ بالتوقيت المحلي** عبر `timezone` — المحرّك كان يقرأ بـUTC،
* فذروة 16:00–20:00 تشتغل 19:00–23:00 في عمّان.
*
* 3. **فتحة العدّاد (`flag`) حقيقية** لا صفر — الفتحة تغطي تكلفة بدء
* الرحلة، وإسقاطها يجعل الرحلة القصيرة خاسرة على السائق.
*
* 4. **الحدّ الأدنى بقرار المالك**: 1.10 دينار · 20 جنيهاً · 150 ليرة.
* النسخة السابقة اشتقّته `perKm × 2` فأنتجت 0.368 ديناراً — ثلث الحقيقي.
*
* 5. **الفئات مشتقّة بمعاملات** لا أرقاماً مستقلّة — الأرقام الخام السابقة
* جعلت الفان أرخص من الاقتصادي في مصر وسوريا (22 مقابل 29.5 جنيهاً)،
* أي أن كل راكب عاقل يطلب أغلى فئة على المشغّل.
*/
export const SERVICE_CLASSES = {
ECONOMY: 'economy',
COMFORT: 'comfort',
LADY: 'lady',
ELECTRIC: 'electric',
VAN: 'van',
DELIVERY: 'delivery',
VIP: 'vip',
FIXED: 'fixed',
SAVER: 'saver',
} as const;
export type ServiceClass = (typeof SERVICE_CLASSES)[keyof typeof SERVICE_CLASSES];
/**
* معاملات الفئات نسبةً إلى الاقتصادي. الترتيب هنا **هو** منطق المنتج:
* أوفر < اقتصادي < كهربائي < ليدي < مريح < فان < VIP.
*/
export const CLASS_MULTIPLIERS: Record<string, number> = {
[SERVICE_CLASSES.SAVER]: 0.85,
[SERVICE_CLASSES.DELIVERY]: 0.9,
[SERVICE_CLASSES.ECONOMY]: 1.0,
[SERVICE_CLASSES.FIXED]: 1.0,
[SERVICE_CLASSES.ELECTRIC]: 1.05,
[SERVICE_CLASSES.LADY]: 1.1,
[SERVICE_CLASSES.COMFORT]: 1.25,
[SERVICE_CLASSES.VAN]: 1.45,
[SERVICE_CLASSES.VIP]: 1.75,
};
/**
* الأساس الاقتصادي بالدينار — مأخوذ من التعرفة العاملة في `SeedService`
* لا من أرقام النظام القديم.
*/
const JOD_BASE = {
flag: 0.39,
perKm: 0.28,
perMin: 0.06,
waitingPerMin: 0.48,
bookingFee: 0.25,
};
export interface CountryTariff {
countryPack: string;
currency: string;
timezone: string;
minFare: number;
/** معامل التحويل من الدينار — مشتقّ من الحدّ الأدنى لا من سعر صرف يومي. */
scale: number;
commissionPercent: number;
rounding: { increment: number; mode: 'nearest' | 'up' | 'down' };
/** سعر الخط الثابت الافتراضي (فئة `fixed`) — يضبطه المشغّل لكل خط. */
fixedFare: number;
}
/**
* `scale` مشتقّ من الحدّ الأدنى الذي أقرّه المالك (20 ÷ 1.10 لمصر،
* 150 ÷ 1.10 لسوريا): مرساة واحدة تعني أن كل الأرقام تتحرك معاً حين
* يتغير الحدّ الأدنى، بدل جدولين ينحرف أحدهما عن الآخر.
*/
export const COUNTRY_TARIFFS: CountryTariff[] = [
{
countryPack: 'jo',
currency: 'JOD',
timezone: 'Asia/Amman',
minFare: 1.1,
scale: 1,
commissionPercent: 14.0,
rounding: { increment: 0.05, mode: 'nearest' },
fixedFare: 2.5,
},
{
countryPack: 'eg',
currency: 'EGP',
timezone: 'Africa/Cairo',
minFare: 20,
scale: 20 / 1.1,
commissionPercent: 12.0,
rounding: { increment: 1, mode: 'nearest' },
fixedFare: 45,
},
{
countryPack: 'sy',
currency: 'SYP',
timezone: 'Asia/Damascus',
minFare: 150,
scale: 150 / 1.1,
commissionPercent: 11.0,
rounding: { increment: 5, mode: 'nearest' },
fixedFare: 350,
},
];
/** النوافذ الزمنية — بالتوقيت المحلي، وتغطي اليوم كاملاً بلا ثغرة. */
export const WINDOWS = [
{ name: 'morning_peak', from: '07:00', to: '09:30', multiplier: 1.25 },
{ name: 'normal_day', from: '09:30', to: '16:00', multiplier: 1.0 },
{ name: 'evening_peak', from: '16:00', to: '19:30', multiplier: 1.25 },
{ name: 'normal_evening', from: '19:30', to: '23:00', multiplier: 1.0 },
{ name: 'late_night', from: '23:00', to: '07:00', multiplier: 1.15 },
];
/** تدوير لخانة معقولة بحجم العملة — 3 كسور بالدينار، صحيح بالليرة. */
function money(value: number, currency: string): number {
const decimals = currency === 'JOD' ? 3 : currency === 'EGP' ? 2 : 0;
return Number(value.toFixed(decimals));
}
export function countryTariff(countryPack: string): CountryTariff {
return (
COUNTRY_TARIFFS.find((c) => c.countryPack === countryPack) ??
COUNTRY_TARIFFS[0] // الأردن افتراضاً — نفس افتراض `country_pack` في الكيان
);
}
/** يبني تعريف تعرفة كامل لـ(بلد × فئة خدمة). */
export function buildDefinition(
country: CountryTariff,
serviceClass: string,
): TariffDefinition {
const classMultiplier = CLASS_MULTIPLIERS[serviceClass] ?? 1;
const rate = (base: number, windowMultiplier: number) =>
money(base * country.scale * classMultiplier * windowMultiplier, country.currency);
const isFixed = serviceClass === SERVICE_CLASSES.FIXED;
const definition: TariffDefinition = {
currency: country.currency,
// المنطقة الزمنية شرط صحة النوافذ — بدونها يقرأ المحرّك بـUTC.
timezone: country.timezone,
mode: isFixed ? 'fixed_quote' : 'time_and_distance',
rounding: country.rounding,
windows: WINDOWS.map((w) => ({
name: w.name,
from: w.from,
to: w.to,
flag: rate(JOD_BASE.flag, w.multiplier),
per_km: rate(JOD_BASE.perKm, w.multiplier),
per_min: rate(JOD_BASE.perMin, w.multiplier),
// الانتظار لا يتبع معامل الفئة: دقيقة انتظار السائق واحدة مهما كانت
// فئة السيارة — الفرق في السيارة لا في الوقت.
per_min_waiting: money(
JOD_BASE.waitingPerMin * country.scale * w.multiplier,
country.currency,
),
})),
booking_fee: money(JOD_BASE.bookingFee * country.scale, country.currency),
min_fare: country.minFare,
free_waiting_min: 5,
surge: { enabled: false },
commission: { percent: country.commissionPercent },
};
if (isFixed) {
definition.fixed_fare = country.fixedFare;
// السعر الثابت لا حدّ أدنى له ولا رسم حجز: المعلَن هو المتفَق عليه،
// ورفعه بحدّ أدنى يعني إعلان سعر وتحصيل غيره.
definition.min_fare = 0;
definition.booking_fee = 0;
}
return definition;
}