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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user