feat: rebuild pricing on Siro's live kazan table + close two fare leaks

## الأرقام صارت من جدول `kazan` الحيّ لا من تقدير
العمولة 11٪/12٪/14٪ مطابقة حرفياً. وثلاثة تصحيحات على الخام:
- **محورا الأردن مقلوبان**: أعمدة الفئات تحمل سعر الدقيقة (0.043) وأعمدة
  النوافذ سعر الكيلومتر (0.184) — عكس مصر وسوريا. قراءتها كما هي تجعل
  رحلة 27 كم بأقلّ من دينارين.
- **نِسَب الفئات في مصر وسوريا معطوبة**: الكهربائي 0.23 والفان 0.25–0.40 من
  الاقتصادي، أي فان أرخص من اقتصادي. نِسَب الأردن وحدها متماسكة فاعتُمدت.
- سعر الكيلو في مصر 4 جنيهات لا 3.5 (قرار المالك).

## البنية صارت مطابقة لسيرو — وهي ما سيكتب فيه بوت استخبارات الأسعار
سعر الكيلو يتغيّر بالفئة فقط (رقم يعرفه السوق)، وسعر الدقيقة بالنافذة فقط.
**لا فتحة عدّاد** (`startPrice = 0` في كل صفوف سيرو): الحدّ الأدنى أرضية لا
رسم يُضاف — «ما في رحلة أقل من 20 جنيهاً». `fuelPrice` محفوظ كمدخل للبوت.

## تسريبان أغلقهما الاختبار الحيّ (بموافقة المالك)
1. **فئة مجهولة = رحلة مجانية**: `service_class` يأتي من الطلب بلا تحقّق،
   والاستثناء كان يُبتلع فتُنشأ رحلة بـ`quoted_fare = null` ثم تُسوّى بصفر.
   صار الطلب يُرفض بـ400 — الفشل الصريح أأمن من الصامت.
2. **`fixed` لا تُزرع**: سعر ثابت بلا جدول خطوط يعني رقماً واحداً لكل
   الوجهات — أظهر الاختبار رحلة 55.8 كم بـ2.5 ديناراً مقابل 23.2 للاقتصادي.

300 اختبار خضر.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-19 01:38:20 +03:00
co-authored by Claude Opus 4.8
parent f58f2e2e2c
commit e9beeb90b4
9 changed files with 250 additions and 165 deletions
+85 -9
View File
@@ -6,6 +6,8 @@ import {
buildDefinition,
COUNTRY_TARIFFS as countries,
SERVICE_CLASSES as ServiceClasses,
SEEDED_CLASSES,
CLASS_MULTIPLIERS,
WINDOWS,
countryTariff as byPack,
} from './default-tariffs';
@@ -34,27 +36,39 @@ describe('تعرفة الانطلاق — الحدود الدنيا بقرار
}
});
it('فتحة العدّاد موجودة في كل نافذة ولكل بلد — لا صفر', () => {
it('**لا فتحة عدّاد ولا رسم حجز** — الأرضية هي الآلية', () => {
// `startPrice = 0` في كل صفوف `kazan`، وقرار المالك أن «ما في رحلة أقل
// من 20 جنيهاً» تُنفَّذ كأرضية لا كرسم يُضاف فوق كل أجرة.
for (const country of countries) {
for (const w of def(country.countryPack, ServiceClasses.ECONOMY).windows) {
// الفتحة صفراً كانت تجعل الرحلة القصيرة خاسرة على السائق.
expect(w.flag).toBeGreaterThan(0);
}
const d = def(country.countryPack, ServiceClasses.ECONOMY);
expect(d.booking_fee).toBe(0);
for (const w of d.windows) expect(w.flag).toBe(0);
}
});
it('الأرضية ترفع الرحلة القصيرة فقط — الطويلة تُحاسَب بالعدّاد', () => {
const d = def('eg', ServiceClasses.ECONOMY);
const short = TariffEngine.quote(d, { distanceKm: 1, durationMin: 3, at: NOON_AMMAN });
const long = TariffEngine.quote(d, { distanceKm: 30, durationMin: 40, at: NOON_AMMAN });
expect(short.total).toBe(20); // الأرضية
expect(long.total).toBeGreaterThan(100); // العدّاد وحده
});
});
describe('تعرفة الانطلاق — ترتيب الفئات', () => {
// الخلل الذي كسر النسخة السابقة: الفان أرخص من الاقتصادي في مصر وسوريا،
// فيصير الخيار العقلاني لكل راكب هو أغلى فئة على المشغّل.
// الترتيب من نِسَب `kazan` (صفّ الأردن): الفان **أغلى من VIP** — مركبة
// بسبعة مقاعد أعلى كلفة من سيارة أفخم.
const ordered = [
ServiceClasses.SAVER,
ServiceClasses.DELIVERY,
ServiceClasses.ECONOMY,
ServiceClasses.ELECTRIC,
ServiceClasses.LADY,
ServiceClasses.ELECTRIC,
ServiceClasses.COMFORT,
ServiceClasses.VAN,
ServiceClasses.VIP,
ServiceClasses.VAN,
];
for (const country of countries) {
@@ -62,8 +76,8 @@ describe('تعرفة الانطلاق — ترتيب الفئات', () => {
const fares = ordered.map(
(cls) =>
TariffEngine.quote(def(country.countryPack, cls), {
distanceKm: 5,
durationMin: 15,
distanceKm: 40, // مسافة كبيرة تتجاوز الأرضية في كل الفئات
durationMin: 50,
at: NOON_AMMAN,
}).total,
);
@@ -171,3 +185,65 @@ describe('العمولة', () => {
expect(c.amount).toBeCloseTo(q.total * 0.14, 2);
});
});
describe('مطابقة جدول kazan (بيانات سيرو الحيّة)', () => {
it('العمولة مطابقة حرفياً: 11٪ سوريا · 12٪ مصر · 14٪ الأردن', () => {
expect(byPack('sy').commissionPercent).toBe(11);
expect(byPack('eg').commissionPercent).toBe(12);
expect(byPack('jo').commissionPercent).toBe(14);
});
it('**محورا الأردن مقروءان بالاتجاه الصحيح** — الكيلو أغلى من الدقيقة', () => {
// في `kazan` كان صفّ الأردن مقلوباً: أعمدة الفئات تحمل سعر الدقيقة
// (0.043) وأعمدة النوافذ سعر الكيلومتر (0.184). قراءته كما هو تجعل
// الكيلومتر بـ0.043 ديناراً — أي رحلة 27 كم بأقلّ من دينارين.
const w = def('jo', ServiceClasses.ECONOMY).windows[1]; // normal_day
expect(w.per_km).toBe(0.184);
expect(w.per_min).toBe(0.043);
expect(w.per_km).toBeGreaterThan(w.per_min);
});
it('سعر الكيلو في مصر 4 جنيهات (قرار المالك) لا 3.5', () => {
expect(byPack('eg').perKm).toBe(4.0);
});
it('**سعر الكيلو ثابت عبر النوافذ** — الوقت يحرّك الدقيقة لا المسافة', () => {
// «الكيلو بأربعة جنيه» رقم يعرفه السوق، فلا يصحّ أن يتذبذب بالساعة.
for (const country of countries) {
const d = def(country.countryPack, ServiceClasses.ECONOMY);
const kms = new Set(d.windows.map((w) => w.per_km));
expect(kms.size).toBe(1);
// بينما الدقيقة تتغيّر فعلاً بين النوافذ (حيث تختلف أرقام البلد).
const mins = new Set(d.windows.map((w) => w.per_min));
expect(mins.size).toBeGreaterThan(1);
}
});
it('نِسَب الفئات معطوبة في kazan لمصر وسوريا — نِسَب الأردن هي المعتمدة', () => {
// الخام: الكهربائي 0.23 والفان 0.25–0.40 من الاقتصادي، أي فان أرخص من
// اقتصادي. المعتمد هنا متصاعد ولا ينعكس.
expect(CLASS_MULTIPLIERS.van).toBeGreaterThan(CLASS_MULTIPLIERS.economy);
expect(CLASS_MULTIPLIERS.electric).toBeGreaterThan(CLASS_MULTIPLIERS.economy);
expect(CLASS_MULTIPLIERS.delivery).toBeLessThan(CLASS_MULTIPLIERS.economy);
});
it('سعر الوقود محفوظ لبوت استخبارات الأسعار', () => {
expect(byPack('sy').fuelPrice).toBe(110);
expect(byPack('eg').fuelPrice).toBe(13);
expect(byPack('jo').fuelPrice).toBe(1);
});
});
describe('فئة fixed لا تُزرع', () => {
it('**مستثناة من الزرع** — سعر ثابت بلا جدول خطوط تسريب', () => {
// أثبت الاختبار الحيّ: رحلة 55.8 كم بـ2.5 ديناراً مقابل 23.2 للاقتصادي.
expect(SEEDED_CLASSES).not.toContain(ServiceClasses.FIXED);
expect(SEEDED_CLASSES).toHaveLength(8);
});
it('تبقى معرَّفة في الكتالوج — تُفعَّل حين يُبنى جدول الخطوط', () => {
expect(Object.values(ServiceClasses)).toContain(ServiceClasses.FIXED);
expect(def('jo', ServiceClasses.FIXED).mode).toBe('fixed_quote');
});
});