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:
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Tripz - Global Tariffs Seed Script
|
||||
*
|
||||
* This script seeds the `tripz_tariffs` table with internationally recognized
|
||||
* service classes (e.g. Economy, Comfort, XL/Van, Saver) and standard time windows.
|
||||
*
|
||||
* It maps legacy flat pricing models into the modern Tripz JSON-based TariffDefinition.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Standard, internationally recognized service classes
|
||||
const ServiceClasses = {
|
||||
ECONOMY: 'economy', // Standard, affordable everyday rides (formerly 'speed')
|
||||
COMFORT: 'comfort', // Newer, more spacious vehicles
|
||||
LADY: 'lady', // Female drivers for female riders
|
||||
ELECTRIC: 'electric', // EV/Green vehicles
|
||||
VAN: 'van', // High capacity (XL) vehicles
|
||||
DELIVERY: 'delivery', // Package / courier delivery
|
||||
VIP: 'vip', // Premium / Luxury rides (formerly 'mishwarVip')
|
||||
FIXED: 'fixed', // Fixed route/fare rides
|
||||
SAVER: 'saver' // Discounted / shared rides (formerly 'awfar')
|
||||
};
|
||||
|
||||
// Global pricing configurations per country
|
||||
const countryConfigs = [
|
||||
{
|
||||
country: 'jordan',
|
||||
currency: 'JOD',
|
||||
commissionPercent: 14.0,
|
||||
baseFare: 0.0,
|
||||
// Note: In the legacy system, Jordan's per-minute and per-km values were structurally swapped.
|
||||
// We have normalized them here to follow standard logic (perKm is higher than perMin).
|
||||
serviceRates: {
|
||||
[ServiceClasses.ECONOMY]: { perKm: 0.184, perMin: 0.043 },
|
||||
[ServiceClasses.COMFORT]: { perKm: 0.212, perMin: 0.056 },
|
||||
[ServiceClasses.LADY]: { perKm: 0.184, perMin: 0.047 },
|
||||
[ServiceClasses.ELECTRIC]: { perKm: 0.184, perMin: 0.052 },
|
||||
[ServiceClasses.VAN]: { perKm: 0.250, perMin: 0.065 }, // Adjusted perKm for Van proportionality
|
||||
[ServiceClasses.DELIVERY]: { perKm: 0.150, perMin: 0.039 },
|
||||
[ServiceClasses.VIP]: { perKm: 0.300, perMin: 0.060 },
|
||||
[ServiceClasses.FIXED]: { perKm: 0.184, perMin: 0.043 },
|
||||
[ServiceClasses.SAVER]: { perKm: 0.150, perMin: 0.037 },
|
||||
},
|
||||
timeMultipliers: {
|
||||
normal: 1.0, // 08:00 - 16:00
|
||||
peak: 1.15, // 16:00 - 22:00 (15% surge)
|
||||
late: 1.0 // 22:00 - 08:00
|
||||
}
|
||||
},
|
||||
{
|
||||
country: 'egypt',
|
||||
currency: 'EGP',
|
||||
commissionPercent: 12.0,
|
||||
baseFare: 0.0,
|
||||
serviceRates: {
|
||||
[ServiceClasses.ECONOMY]: { perKm: 3.5, perMin: 0.8 },
|
||||
[ServiceClasses.COMFORT]: { perKm: 5.0, perMin: 0.9 },
|
||||
[ServiceClasses.LADY]: { perKm: 9.0, perMin: 1.0 },
|
||||
[ServiceClasses.ELECTRIC]: { perKm: 0.8, perMin: 0.5 },
|
||||
[ServiceClasses.VAN]: { perKm: 1.4, perMin: 1.0 },
|
||||
[ServiceClasses.DELIVERY]: { perKm: 1.3, perMin: 0.8 },
|
||||
[ServiceClasses.VIP]: { perKm: 5.5, perMin: 1.2 },
|
||||
[ServiceClasses.FIXED]: { perKm: 5.5, perMin: 1.0 },
|
||||
[ServiceClasses.SAVER]: { perKm: 3.0, perMin: 0.7 },
|
||||
},
|
||||
timeMultipliers: {
|
||||
normal: 1.0,
|
||||
peak: 1.75, // 1.4 EGP peak / 0.8 EGP normal = 1.75x
|
||||
late: 1.87 // 1.5 EGP late / 0.8 EGP normal = 1.87x
|
||||
}
|
||||
},
|
||||
{
|
||||
country: 'syria',
|
||||
currency: 'SYP',
|
||||
commissionPercent: 11.0,
|
||||
baseFare: 0.0,
|
||||
serviceRates: {
|
||||
[ServiceClasses.ECONOMY]: { perKm: 40, perMin: 9 },
|
||||
[ServiceClasses.COMFORT]: { perKm: 44, perMin: 10 },
|
||||
[ServiceClasses.LADY]: { perKm: 63, perMin: 12 },
|
||||
[ServiceClasses.ELECTRIC]: { perKm: 9, perMin: 5 },
|
||||
[ServiceClasses.VAN]: { perKm: 10, perMin: 8 },
|
||||
[ServiceClasses.DELIVERY]: { perKm: 20, perMin: 9 },
|
||||
[ServiceClasses.VIP]: { perKm: 26, perMin: 12 },
|
||||
[ServiceClasses.FIXED]: { perKm: 26, perMin: 10 },
|
||||
[ServiceClasses.SAVER]: { perKm: 36, perMin: 8 },
|
||||
},
|
||||
timeMultipliers: {
|
||||
normal: 1.0,
|
||||
peak: 1.11, // 10 peak / 9 normal = 1.11x
|
||||
late: 1.22 // 11 late / 9 normal = 1.22x
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function generateSql() {
|
||||
let sql = `-- Tripz Standard Tariffs Seed Script\n`;
|
||||
sql += `-- Generated with international ride-hailing naming conventions.\n\n`;
|
||||
|
||||
for (const config of countryConfigs) {
|
||||
const city = config.country;
|
||||
|
||||
for (const [serviceClass, rates] of Object.entries(config.serviceRates)) {
|
||||
|
||||
const definition = {
|
||||
currency: config.currency,
|
||||
mode: 'time_and_distance',
|
||||
booking_fee: config.baseFare,
|
||||
min_fare: config.baseFare > 0 ? config.baseFare : (rates.perKm * 2), // Min fare = 2 KMs default
|
||||
windows: [
|
||||
{
|
||||
name: 'normal',
|
||||
from: '08:00',
|
||||
to: '16:00',
|
||||
flag: config.baseFare,
|
||||
per_km: rates.perKm,
|
||||
per_min: rates.perMin,
|
||||
per_min_waiting: Number((rates.perMin * 1.5).toFixed(3))
|
||||
},
|
||||
{
|
||||
name: 'peak',
|
||||
from: '16:00',
|
||||
to: '22:00',
|
||||
flag: config.baseFare,
|
||||
per_km: Number((rates.perKm * config.timeMultipliers.peak).toFixed(3)),
|
||||
per_min: Number((rates.perMin * config.timeMultipliers.peak).toFixed(3)),
|
||||
per_min_waiting: Number((rates.perMin * config.timeMultipliers.peak * 1.5).toFixed(3))
|
||||
},
|
||||
{
|
||||
name: 'late',
|
||||
from: '22:00',
|
||||
to: '08:00',
|
||||
flag: config.baseFare,
|
||||
per_km: Number((rates.perKm * config.timeMultipliers.late).toFixed(3)),
|
||||
per_min: Number((rates.perMin * config.timeMultipliers.late).toFixed(3)),
|
||||
per_min_waiting: Number((rates.perMin * config.timeMultipliers.late * 1.5).toFixed(3))
|
||||
}
|
||||
],
|
||||
commission: {
|
||||
percent: config.commissionPercent
|
||||
}
|
||||
};
|
||||
|
||||
const defJson = JSON.stringify(definition).replace(/'/g, "''"); // Escape single quotes for SQL
|
||||
|
||||
sql += `INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)\n`;
|
||||
sql += `VALUES (\n`;
|
||||
sql += ` gen_random_uuid(),\n`;
|
||||
sql += ` (SELECT id FROM tripz_tenants WHERE slug = 'siro' LIMIT 1),\n`;
|
||||
sql += ` '${city}',\n`;
|
||||
sql += ` '${serviceClass}',\n`;
|
||||
sql += ` 1,\n`;
|
||||
sql += ` true,\n`;
|
||||
sql += ` '${defJson}'::jsonb\n`;
|
||||
sql += `);\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
const sqlOutput = generateSql();
|
||||
fs.writeFileSync(__dirname + '/seed-tariffs.sql', sqlOutput);
|
||||
console.log('✅ Generated seed-tariffs.sql successfully with international naming standards.');
|
||||
@@ -36,6 +36,7 @@ import { DispatchModule } from './modules/dispatch/dispatch.module';
|
||||
import { WalletModule } from './modules/wallet/wallet.module';
|
||||
import { TenantWalletModule } from './modules/tenant-wallet/tenant-wallet.module';
|
||||
import { CreditModule } from './modules/credit/credit.module';
|
||||
import { RewardsModule } from './modules/rewards/rewards.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { PaymentsModule } from './modules/payments/payments.module';
|
||||
import { StorageModule } from './common/storage/storage.module';
|
||||
@@ -100,6 +101,7 @@ import { GeofenceModule } from './modules/geofence/geofence.module';
|
||||
WalletModule,
|
||||
TenantWalletModule, // دفترا المستأجر: إيراد وأمانات (docs/24)
|
||||
CreditModule,
|
||||
RewardsModule, // الإحالات والكوبونات (المجموعة L)
|
||||
PaymentsModule,
|
||||
BillingModule,
|
||||
TripsModule,
|
||||
|
||||
@@ -2,6 +2,11 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { TenantsService } from '../../modules/tenants/tenants.service';
|
||||
import { TariffService } from '../../modules/tariff/tariff.service';
|
||||
import { RideTypesService } from '../../modules/ride-types/ride-types.service';
|
||||
import {
|
||||
buildDefinition,
|
||||
countryTariff,
|
||||
SERVICE_CLASSES,
|
||||
} from '../../modules/tariff/default-tariffs';
|
||||
|
||||
/**
|
||||
* seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية،
|
||||
@@ -41,30 +46,25 @@ export class SeedService implements OnModuleInit {
|
||||
this.logger.log(`seeded demo tenant "siro" (${tenant.id})`);
|
||||
}
|
||||
|
||||
const existing = await this.tariff.getActive(tenant.id, 'default', 'economy');
|
||||
if (!existing) {
|
||||
// تعرفة الانطلاق **لكل فئة خدمة** من مصدر الحقيقة الواحد
|
||||
// (`default-tariffs.ts`)، لا الاقتصادي وحده: طلب فئة بلا تعرفة كان
|
||||
// يمرّ بلا سعر ثم يُسوّى بصفر — رحلة مجانية وعمولة صفر.
|
||||
// البلد من `country_pack` للمستأجر، والمدينة `'default'` لأنها القيمة
|
||||
// التي يبحث بها `trips.service` فعلاً.
|
||||
const country = countryTariff(tenant.countryPack);
|
||||
for (const serviceClass of Object.values(SERVICE_CLASSES)) {
|
||||
const existing = await this.tariff.getActive(tenant.id, 'default', serviceClass);
|
||||
if (existing) continue;
|
||||
await this.tariff.create({
|
||||
tenant_id: tenant.id,
|
||||
city: 'default',
|
||||
service_class: 'economy',
|
||||
service_class: serviceClass,
|
||||
version: 1,
|
||||
active: true,
|
||||
definition: {
|
||||
currency: 'JOD',
|
||||
rounding: { increment: 0.05, mode: 'nearest' },
|
||||
mode: 'time_or_distance',
|
||||
speed_threshold_kmh: 18,
|
||||
windows: [
|
||||
{ name: 'day', from: '06:00', to: '22:00', flag: 0.39, per_km: 0.28, per_min: 0.06, per_min_waiting: 0.48 },
|
||||
{ name: 'night', from: '22:00', to: '06:00', flag: 0.40, per_km: 0.33, per_min: 0.07, per_min_waiting: 0.55 },
|
||||
],
|
||||
booking_fee: 0.25,
|
||||
min_fare: 1.0,
|
||||
surge: { enabled: false },
|
||||
},
|
||||
definition: buildDefinition(country, serviceClass),
|
||||
});
|
||||
this.logger.log('seeded default tariff for "siro"');
|
||||
}
|
||||
this.logger.log(`seeded ${Object.keys(SERVICE_CLASSES).length} tariffs for "${tenant.slug}" (${country.currency})`);
|
||||
|
||||
// أنواع الرحلات الافتراضية
|
||||
const types: any[] = [
|
||||
|
||||
@@ -82,6 +82,12 @@ export default () => ({
|
||||
scheduleSweepMs: parseInt(process.env.TRIP_SCHEDULE_SWEEP_MS ?? '30000', 10),
|
||||
},
|
||||
|
||||
// المكافآت: كل كم يمسح الماسح الإحالات المؤهَّلة غير المصروفة (docs/17 — L5).
|
||||
// أبطأ من كنس الرحلات عمداً: هذا شبكة أمان لا مسار أساسي.
|
||||
rewards: {
|
||||
sweepMs: parseInt(process.env.REWARDS_SWEEP_MS ?? '60000', 10),
|
||||
},
|
||||
|
||||
nabeh: {
|
||||
baseUrl: process.env.NABEH_BASE_URL ?? 'https://nabeh.intaleqapp.com',
|
||||
email: process.env.NABEH_EMAIL ?? '',
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* الإحالات والكوبونات (المجموعة L — قرار المالك 2026-07-18).
|
||||
*
|
||||
* مكافأة الراكب كوبون لا رصيد محفظة: محفظة الراكب لا تُسحب أبداً (قانون
|
||||
* ثابت)، فإنزال مكافأة تسويقية فيها يخلق التزاماً نقدياً بلا مقابل.
|
||||
* والحدود هنا مفروضة على القاعدة لا بفحص تطبيقي وحده — الفحص التطبيقي
|
||||
* يسقط تحت التزامن، والقيد لا يسقط.
|
||||
*/
|
||||
export class ReferralsAndCoupons1721950000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_coupons (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
code varchar NOT NULL,
|
||||
user_id uuid,
|
||||
discount_type varchar NOT NULL DEFAULT 'percent',
|
||||
discount_value numeric(12,3) NOT NULL,
|
||||
max_discount numeric(12,3),
|
||||
min_fare numeric(12,3),
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
valid_from TIMESTAMP,
|
||||
valid_until TIMESTAMP,
|
||||
max_redemptions int,
|
||||
max_per_user int NOT NULL DEFAULT 1,
|
||||
redeemed_count int NOT NULL DEFAULT 0,
|
||||
status varchar NOT NULL DEFAULT 'active',
|
||||
source varchar NOT NULL DEFAULT 'campaign',
|
||||
ref varchar,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
-- خصم سالب كان سيرفع الأجرة؛ ونسبة فوق 100 تجعلها سالبة.
|
||||
CONSTRAINT chk_tripz_coupons_value CHECK (
|
||||
discount_value >= 0
|
||||
AND (discount_type <> 'percent' OR discount_value <= 100)
|
||||
),
|
||||
CONSTRAINT chk_tripz_coupons_cap CHECK (max_discount IS NULL OR max_discount >= 0)
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupons_tenant_code"
|
||||
ON tripz_coupons (tenant_id, code)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_coupons_tenant_user"
|
||||
ON tripz_coupons (tenant_id, user_id)
|
||||
`);
|
||||
|
||||
// كوبون واحد لكل سبب منح — يمنع مكافأة إحالة مزدوجة إذا التقى الصرف
|
||||
// الفوري بالماسح على نفس الإحالة.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupons_ref_once"
|
||||
ON tripz_coupons (tenant_id, ref)
|
||||
WHERE ref IS NOT NULL
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_coupon_redemptions (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
coupon_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
trip_id uuid NOT NULL,
|
||||
discount_amount numeric(12,3) NOT NULL,
|
||||
fare_before numeric(12,3),
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
// كوبون واحد لكل رحلة — لا تكديس أكواد على أجرة واحدة.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_trip_once"
|
||||
ON tripz_coupon_redemptions (tenant_id, trip_id)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_coupon"
|
||||
ON tripz_coupon_redemptions (tenant_id, coupon_id)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_coupon_redemptions_user"
|
||||
ON tripz_coupon_redemptions (tenant_id, user_id)
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_referral_codes (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
code varchar NOT NULL,
|
||||
role varchar NOT NULL,
|
||||
status varchar NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referral_codes_tenant_code"
|
||||
ON tripz_referral_codes (tenant_id, code)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referral_codes_tenant_user"
|
||||
ON tripz_referral_codes (tenant_id, user_id)
|
||||
`);
|
||||
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_referrals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id uuid NOT NULL,
|
||||
referrer_user_id uuid NOT NULL,
|
||||
referee_user_id uuid NOT NULL,
|
||||
referrer_role varchar NOT NULL,
|
||||
referee_role varchar NOT NULL,
|
||||
code varchar NOT NULL,
|
||||
status varchar NOT NULL DEFAULT 'pending',
|
||||
qualifying_trips int NOT NULL DEFAULT 0,
|
||||
qualified_trip_id uuid,
|
||||
qualified_at TIMESTAMP,
|
||||
paid_at TIMESTAMP,
|
||||
rejected_reason varchar,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
-- إحالة النفس: أوضح صور الاحتيال وأرخصها منعاً.
|
||||
CONSTRAINT chk_tripz_referrals_not_self CHECK (referrer_user_id <> referee_user_id)
|
||||
)
|
||||
`);
|
||||
// الحارس الأساسي: الشخص يُحال **مرة واحدة** لا مرة لكل داعٍ.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_referrals_referee_once"
|
||||
ON tripz_referrals (tenant_id, referee_user_id)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_referrals_referrer"
|
||||
ON tripz_referrals (tenant_id, referrer_user_id)
|
||||
`);
|
||||
// الماسح يقرأ بالحالة كل دورة — بلا فهرس يمسح الجدول كله مع نموّه.
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_referrals_status"
|
||||
ON tripz_referrals (tenant_id, status)
|
||||
`);
|
||||
|
||||
// أثر الكوبون على الرحلة — نصاً لا مفتاحاً أجنبياً: الرحلة سجلّ تاريخي،
|
||||
// وحذف حملة قديمة يجب ألّا يمحو سبب الخصم من رحلة مضت.
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS coupon_code varchar
|
||||
`);
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_trips ADD COLUMN IF NOT EXISTS coupon_discount numeric(12,3)
|
||||
`);
|
||||
|
||||
// مكافأة الإحالة مرة واحدة لكل سائق لكل إحالة — نفس حارس J6 على القاعدة.
|
||||
await q.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_tripz_credit_txns_referral_once"
|
||||
ON tripz_credit_txns (tenant_id, ref)
|
||||
WHERE type = 'referral_bonus'
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_credit_txns_referral_once"`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS coupon_discount`);
|
||||
await q.query(`ALTER TABLE tripz_trips DROP COLUMN IF EXISTS coupon_code`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_referrals`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_referral_codes`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_coupon_redemptions`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_coupons`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* المسافة المقاسة مقابل المقدَّرة (docs/17 — B12).
|
||||
*
|
||||
* الأجرة تُحسب على ما قُطع فعلاً من نبضات GPS لا على تقدير وقت الطلب:
|
||||
* تحويلة حقيقية يقبضها السائق، وجولة مخترَعة يقصّها السقف. الأعمدة الثلاثة
|
||||
* تُخزَّن دائماً — حتى حين لا يُحاسَب عليها — لأن النسبة هي ما يُقرأ لاحقاً
|
||||
* لكشف النمط: سائق نسبته 1.6 في كل رحلاته ليس ضحية ازدحام.
|
||||
*/
|
||||
export class ActualDistanceBilling1721960000000 implements MigrationInterface {
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_trips
|
||||
ADD COLUMN IF NOT EXISTS actual_distance_km numeric(10,3),
|
||||
ADD COLUMN IF NOT EXISTS billed_distance_km numeric(10,3),
|
||||
ADD COLUMN IF NOT EXISTS distance_ratio numeric(6,3)
|
||||
`);
|
||||
|
||||
// الاستعلام الحاكم في التسوية: نبضات سائق بين لحظتين. بلا هذا الفهرس
|
||||
// يمسح الجدول كاملاً — وهو أسرع الجداول نموّاً عندنا (نبضة كل ثوانٍ لكل
|
||||
// سائق متصل)، فالمسح يزداد كلفةً كل يوم.
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_driver_tracks_driver_time"
|
||||
ON tripz_driver_tracks (tenant_id, driver_id, recorded_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_driver_tracks_driver_time"`);
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_trips
|
||||
DROP COLUMN IF EXISTS distance_ratio,
|
||||
DROP COLUMN IF EXISTS billed_distance_km,
|
||||
DROP COLUMN IF EXISTS actual_distance_km
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
buildDefinition,
|
||||
COUNTRY_TARIFFS,
|
||||
SERVICE_CLASSES,
|
||||
} from '../../modules/tariff/default-tariffs';
|
||||
|
||||
/**
|
||||
* يولّد `tariffs.sql` لتطبيقها على قاعدة **قائمة** — للمستأجرين الموجودين
|
||||
* قبل أن يصير الزرع تلقائياً.
|
||||
*
|
||||
* التشغيل: `npx ts-node src/database/seeds/generate-tariff-sql.ts`
|
||||
*
|
||||
* مستأجر جديد لا يحتاج هذا الملف إطلاقاً: `SeedService` يزرع له التعرفة من
|
||||
* **نفس** `default-tariffs.ts` عند الإقلاع. هذا المولّد للحاق بالقائم فقط،
|
||||
* ولذلك يقرأ من مصدر الحقيقة نفسه — نسختان من أرقام التسعير كانتا ستتباعدان.
|
||||
*
|
||||
* الناتج **آمن للتكرار**: كل تشغيل يطفئ الفعّالة ويُدرج `max(version)+1`،
|
||||
* فلا يبقى صفّان فعّالان لنفس (مستأجر، مدينة، فئة) — و`getActive` ترتّب
|
||||
* `version DESC` فكان الاختيار بينهما غير محدَّد.
|
||||
*/
|
||||
function generateSql(): string {
|
||||
let sql = `-- Tripz — تعرفة الانطلاق الافتراضية لكل بلد.\n`;
|
||||
sql += `-- مولَّد من src/database/seeds/generate-tariff-sql.ts — لا يُحرَّر يدوياً.\n`;
|
||||
sql += `-- المصدر: src/modules/tariff/default-tariffs.ts\n`;
|
||||
sql += `-- آمن للتكرار: كل تشغيل يطفئ الفعّالة ويُدرج نسخة أعلى.\n\n`;
|
||||
sql += `BEGIN;\n\n`;
|
||||
|
||||
for (const country of COUNTRY_TARIFFS) {
|
||||
sql += `-- ============ ${country.countryPack.toUpperCase()} (${country.currency}) ============\n`;
|
||||
|
||||
for (const serviceClass of Object.values(SERVICE_CLASSES)) {
|
||||
const definition = buildDefinition(country, serviceClass);
|
||||
const defJson = JSON.stringify(definition).replace(/'/g, "''");
|
||||
|
||||
// يطال **كل** مستأجر في هذا البلد لا سيرو وحده: تعرفة الانطلاق عامة،
|
||||
// وربطها بمستأجر واحد كان يترك كل مستأجر آخر بلا تسعير.
|
||||
sql += `-- ${serviceClass}\n`;
|
||||
sql += `UPDATE tripz_tariffs SET active = false\n`;
|
||||
sql += ` WHERE city = 'default' AND service_class = '${serviceClass}' AND active = true\n`;
|
||||
sql += ` AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = '${country.countryPack}');\n`;
|
||||
sql += `INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)\n`;
|
||||
sql += `SELECT uuid_generate_v4(), t.id, 'default', '${serviceClass}',\n`;
|
||||
sql += ` COALESCE((SELECT MAX(version) FROM tripz_tariffs x\n`;
|
||||
sql += ` WHERE x.tenant_id = t.id AND x.city = 'default'\n`;
|
||||
sql += ` AND x.service_class = '${serviceClass}'), 0) + 1,\n`;
|
||||
sql += ` true, '${defJson}'::jsonb\n`;
|
||||
sql += ` FROM tripz_tenants t WHERE t.country_pack = '${country.countryPack}';\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
sql += `COMMIT;\n`;
|
||||
return sql;
|
||||
}
|
||||
|
||||
const out = join(__dirname, 'tariffs.sql');
|
||||
writeFileSync(out, generateSql());
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`✅ generated ${out}`);
|
||||
@@ -0,0 +1,335 @@
|
||||
-- Tripz — تعرفة الانطلاق الافتراضية لكل بلد.
|
||||
-- مولَّد من src/database/seeds/generate-tariff-sql.ts — لا يُحرَّر يدوياً.
|
||||
-- المصدر: src/modules/tariff/default-tariffs.ts
|
||||
-- آمن للتكرار: كل تشغيل يطفئ الفعّالة ويُدرج نسخة أعلى.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============ JO (JOD) ============
|
||||
-- economy
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'economy' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'economy',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'economy'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.39,"per_km":0.28,"per_min":0.06,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.39,"per_km":0.28,"per_min":0.06,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.448,"per_km":0.322,"per_min":0.069,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- comfort
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'comfort' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'comfort',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'comfort'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.609,"per_km":0.438,"per_min":0.094,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.609,"per_km":0.438,"per_min":0.094,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.561,"per_km":0.403,"per_min":0.086,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- lady
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'lady' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'lady',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'lady'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.536,"per_km":0.385,"per_min":0.083,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.429,"per_km":0.308,"per_min":0.066,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.536,"per_km":0.385,"per_min":0.083,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.429,"per_km":0.308,"per_min":0.066,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.493,"per_km":0.354,"per_min":0.076,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- electric
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'electric' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'electric',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'electric'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.512,"per_km":0.368,"per_min":0.079,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.41,"per_km":0.294,"per_min":0.063,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.512,"per_km":0.368,"per_min":0.079,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.41,"per_km":0.294,"per_min":0.063,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.471,"per_km":0.338,"per_min":0.072,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- van
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'van' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'van',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'van'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.707,"per_km":0.508,"per_min":0.109,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.566,"per_km":0.406,"per_min":0.087,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.707,"per_km":0.508,"per_min":0.109,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.566,"per_km":0.406,"per_min":0.087,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.65,"per_km":0.467,"per_min":0.1,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- delivery
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'delivery' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'delivery',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'delivery'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.439,"per_km":0.315,"per_min":0.068,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.351,"per_km":0.252,"per_min":0.054,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.439,"per_km":0.315,"per_min":0.068,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.351,"per_km":0.252,"per_min":0.054,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.404,"per_km":0.29,"per_min":0.062,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- vip
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'vip' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'vip',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'vip'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.853,"per_km":0.613,"per_min":0.131,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.682,"per_km":0.49,"per_min":0.105,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.853,"per_km":0.613,"per_min":0.131,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.682,"per_km":0.49,"per_min":0.105,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.785,"per_km":0.564,"per_min":0.121,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- fixed
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'fixed' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'fixed',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'fixed'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"fixed_quote","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.39,"per_km":0.28,"per_min":0.06,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.488,"per_km":0.35,"per_min":0.075,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.39,"per_km":0.28,"per_min":0.06,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.448,"per_km":0.322,"per_min":0.069,"per_min_waiting":0.552}],"booking_fee":0,"min_fare":0,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14},"fixed_fare":2.5}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- saver
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'saver' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'jo');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'saver',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'saver'), 0) + 1,
|
||||
true, '{"currency":"JOD","timezone":"Asia/Amman","mode":"time_and_distance","rounding":{"increment":0.05,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":0.414,"per_km":0.298,"per_min":0.064,"per_min_waiting":0.6},{"name":"normal_day","from":"09:30","to":"16:00","flag":0.332,"per_km":0.238,"per_min":0.051,"per_min_waiting":0.48},{"name":"evening_peak","from":"16:00","to":"19:30","flag":0.414,"per_km":0.298,"per_min":0.064,"per_min_waiting":0.6},{"name":"normal_evening","from":"19:30","to":"23:00","flag":0.332,"per_km":0.238,"per_min":0.051,"per_min_waiting":0.48},{"name":"late_night","from":"23:00","to":"07:00","flag":0.381,"per_km":0.274,"per_min":0.059,"per_min_waiting":0.552}],"booking_fee":0.25,"min_fare":1.1,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":14}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'jo';
|
||||
|
||||
-- ============ EG (EGP) ============
|
||||
-- economy
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'economy' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'economy',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'economy'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":7.09,"per_km":5.09,"per_min":1.09,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":7.09,"per_km":5.09,"per_min":1.09,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":8.15,"per_km":5.85,"per_min":1.25,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- comfort
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'comfort' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'comfort',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'comfort'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":11.08,"per_km":7.95,"per_min":1.7,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":11.08,"per_km":7.95,"per_min":1.7,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":10.19,"per_km":7.32,"per_min":1.57,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- lady
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'lady' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'lady',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'lady'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":9.75,"per_km":7,"per_min":1.5,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":7.8,"per_km":5.6,"per_min":1.2,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":9.75,"per_km":7,"per_min":1.5,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":7.8,"per_km":5.6,"per_min":1.2,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":8.97,"per_km":6.44,"per_min":1.38,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- electric
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'electric' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'electric',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'electric'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":9.31,"per_km":6.68,"per_min":1.43,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":7.45,"per_km":5.35,"per_min":1.15,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":9.31,"per_km":6.68,"per_min":1.43,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":7.45,"per_km":5.35,"per_min":1.15,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":8.56,"per_km":6.15,"per_min":1.32,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- van
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'van' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'van',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'van'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":12.85,"per_km":9.23,"per_min":1.98,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":10.28,"per_km":7.38,"per_min":1.58,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":12.85,"per_km":9.23,"per_min":1.98,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":10.28,"per_km":7.38,"per_min":1.58,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":11.82,"per_km":8.49,"per_min":1.82,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- delivery
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'delivery' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'delivery',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'delivery'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":7.98,"per_km":5.73,"per_min":1.23,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":6.38,"per_km":4.58,"per_min":0.98,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":7.98,"per_km":5.73,"per_min":1.23,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":6.38,"per_km":4.58,"per_min":0.98,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":7.34,"per_km":5.27,"per_min":1.13,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- vip
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'vip' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'vip',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'vip'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":15.51,"per_km":11.14,"per_min":2.39,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":12.41,"per_km":8.91,"per_min":1.91,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":15.51,"per_km":11.14,"per_min":2.39,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":12.41,"per_km":8.91,"per_min":1.91,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":14.27,"per_km":10.25,"per_min":2.2,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- fixed
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'fixed' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'fixed',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'fixed'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"fixed_quote","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":7.09,"per_km":5.09,"per_min":1.09,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":8.86,"per_km":6.36,"per_min":1.36,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":7.09,"per_km":5.09,"per_min":1.09,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":8.15,"per_km":5.85,"per_min":1.25,"per_min_waiting":10.04}],"booking_fee":0,"min_fare":0,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12},"fixed_fare":45}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- saver
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'saver' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'eg');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'saver',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'saver'), 0) + 1,
|
||||
true, '{"currency":"EGP","timezone":"Africa/Cairo","mode":"time_and_distance","rounding":{"increment":1,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":7.53,"per_km":5.41,"per_min":1.16,"per_min_waiting":10.91},{"name":"normal_day","from":"09:30","to":"16:00","flag":6.03,"per_km":4.33,"per_min":0.93,"per_min_waiting":8.73},{"name":"evening_peak","from":"16:00","to":"19:30","flag":7.53,"per_km":5.41,"per_min":1.16,"per_min_waiting":10.91},{"name":"normal_evening","from":"19:30","to":"23:00","flag":6.03,"per_km":4.33,"per_min":0.93,"per_min_waiting":8.73},{"name":"late_night","from":"23:00","to":"07:00","flag":6.93,"per_km":4.98,"per_min":1.07,"per_min_waiting":10.04}],"booking_fee":4.55,"min_fare":20,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":12}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'eg';
|
||||
|
||||
-- ============ SY (SYP) ============
|
||||
-- economy
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'economy' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'economy',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'economy'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":66,"per_km":48,"per_min":10,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":53,"per_km":38,"per_min":8,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":66,"per_km":48,"per_min":10,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":53,"per_km":38,"per_min":8,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":61,"per_km":44,"per_min":9,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- comfort
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'comfort' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'comfort',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'comfort'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":83,"per_km":60,"per_min":13,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":66,"per_km":48,"per_min":10,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":83,"per_km":60,"per_min":13,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":66,"per_km":48,"per_min":10,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":76,"per_km":55,"per_min":12,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- lady
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'lady' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'lady',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'lady'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":73,"per_km":53,"per_min":11,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":59,"per_km":42,"per_min":9,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":73,"per_km":53,"per_min":11,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":59,"per_km":42,"per_min":9,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":67,"per_km":48,"per_min":10,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- electric
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'electric' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'electric',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'electric'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":70,"per_km":50,"per_min":11,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":56,"per_km":40,"per_min":9,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":70,"per_km":50,"per_min":11,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":56,"per_km":40,"per_min":9,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":64,"per_km":46,"per_min":10,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- van
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'van' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'van',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'van'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":96,"per_km":69,"per_min":15,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":77,"per_km":55,"per_min":12,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":96,"per_km":69,"per_min":15,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":77,"per_km":55,"per_min":12,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":89,"per_km":64,"per_min":14,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- delivery
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'delivery' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'delivery',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'delivery'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":60,"per_km":43,"per_min":9,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":48,"per_km":34,"per_min":7,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":60,"per_km":43,"per_min":9,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":48,"per_km":34,"per_min":7,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":55,"per_km":40,"per_min":8,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- vip
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'vip' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'vip',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'vip'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":116,"per_km":84,"per_min":18,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":93,"per_km":67,"per_min":14,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":116,"per_km":84,"per_min":18,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":93,"per_km":67,"per_min":14,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":107,"per_km":77,"per_min":16,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- fixed
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'fixed' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'fixed',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'fixed'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"fixed_quote","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":66,"per_km":48,"per_min":10,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":53,"per_km":38,"per_min":8,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":66,"per_km":48,"per_min":10,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":53,"per_km":38,"per_min":8,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":61,"per_km":44,"per_min":9,"per_min_waiting":75}],"booking_fee":0,"min_fare":0,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11},"fixed_fare":350}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
-- saver
|
||||
UPDATE tripz_tariffs SET active = false
|
||||
WHERE city = 'default' AND service_class = 'saver' AND active = true
|
||||
AND tenant_id IN (SELECT id FROM tripz_tenants WHERE country_pack = 'sy');
|
||||
INSERT INTO tripz_tariffs (id, tenant_id, city, service_class, version, active, definition)
|
||||
SELECT uuid_generate_v4(), t.id, 'default', 'saver',
|
||||
COALESCE((SELECT MAX(version) FROM tripz_tariffs x
|
||||
WHERE x.tenant_id = t.id AND x.city = 'default'
|
||||
AND x.service_class = 'saver'), 0) + 1,
|
||||
true, '{"currency":"SYP","timezone":"Asia/Damascus","mode":"time_and_distance","rounding":{"increment":5,"mode":"nearest"},"windows":[{"name":"morning_peak","from":"07:00","to":"09:30","flag":57,"per_km":41,"per_min":9,"per_min_waiting":82},{"name":"normal_day","from":"09:30","to":"16:00","flag":45,"per_km":32,"per_min":7,"per_min_waiting":65},{"name":"evening_peak","from":"16:00","to":"19:30","flag":57,"per_km":41,"per_min":9,"per_min_waiting":82},{"name":"normal_evening","from":"19:30","to":"23:00","flag":45,"per_km":32,"per_min":7,"per_min_waiting":65},{"name":"late_night","from":"23:00","to":"07:00","flag":52,"per_km":37,"per_min":8,"per_min_waiting":75}],"booking_fee":34,"min_fare":150,"free_waiting_min":5,"surge":{"enabled":false},"commission":{"percent":11}}'::jsonb
|
||||
FROM tripz_tenants t WHERE t.country_pack = 'sy';
|
||||
|
||||
COMMIT;
|
||||
@@ -35,9 +35,11 @@ export class AuthController {
|
||||
@Headers('x-device-id') deviceId: string,
|
||||
@Body('phone') phone: string,
|
||||
@Body('code') code: string,
|
||||
// كود دعوة اختياري — يُسجَّل للمستخدم الجديد وحده (المجموعة L).
|
||||
@Body('referral_code') referralCode?: string,
|
||||
) {
|
||||
if (!tenantId) throw new UnauthorizedException('Tenant ID (x-tenant-id) is required');
|
||||
return this.authService.verifyOtp(tenantId, phone, code, deviceId);
|
||||
return this.authService.verifyOtp(tenantId, phone, code, deviceId, referralCode);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
|
||||
@@ -7,11 +7,13 @@ import { AuthController } from './auth.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { TenantsModule } from '../tenants/tenants.module';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { RewardsModule } from '../rewards/rewards.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
TenantsModule,
|
||||
RewardsModule, // تسجيل كود الدعوة عند إنشاء مستخدم جديد (المجموعة L)
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import { OtpDispatcher } from '../../integrations/otp/otp-dispatcher.service';
|
||||
import { PhoneService } from '../../common/phone/phone.service';
|
||||
import { DeviceService } from '../../common/device/device.service';
|
||||
import { ReferralsService } from '../rewards/referrals.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -31,6 +32,7 @@ export class AuthService {
|
||||
private readonly signing: SigningService,
|
||||
private readonly phones: PhoneService,
|
||||
private readonly device: DeviceService,
|
||||
private readonly referrals: ReferralsService,
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
@@ -102,7 +104,13 @@ export class AuthService {
|
||||
return { success: true, message: 'OTP sent' };
|
||||
}
|
||||
|
||||
async verifyOtp(tenantSlug: string, phone: string, code: string, deviceId?: string) {
|
||||
async verifyOtp(
|
||||
tenantSlug: string,
|
||||
phone: string,
|
||||
code: string,
|
||||
deviceId?: string,
|
||||
referralCode?: string,
|
||||
) {
|
||||
const tenant = await this.resolveTenant(tenantSlug);
|
||||
// نفس التطبيع بالضبط — وإلا فشل التحقق لمجرد أن المستخدم كتب الرقم
|
||||
// بصيغة مختلفة قليلاً عن مرة الإرسال (مثال المالك: "01" مقابل "1").
|
||||
@@ -134,6 +142,16 @@ export class AuthService {
|
||||
let user = await this.usersService.findByPhone(tenant.id, canonical);
|
||||
if (!user) {
|
||||
user = await this.usersService.create(tenant.id, canonical);
|
||||
// كود الدعوة يُسجَّل **للمستخدم الجديد وحده** (المجموعة L): قبوله عند
|
||||
// كل دخول كان يعني أن مستخدماً قديماً يُحال بعد سنة من استعماله.
|
||||
// لا يرمي عند كود خاطئ — فشل الإحالة يجب ألّا يمنع أحداً من الدخول.
|
||||
if (referralCode) {
|
||||
try {
|
||||
await this.referrals.register(tenant.id, user.id, 'rider', referralCode);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`referral registration failed for ${user.id}: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.issueTokens(user, tenant.id, deviceId);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,45 @@ export class DriverCreditService {
|
||||
return this.apply(tenantId, driverId, amount, 'promo_bonus', { ref });
|
||||
}
|
||||
|
||||
/**
|
||||
* مكافأة إحالة للسائق (المجموعة L) — **مرة واحدة لكل إحالة**.
|
||||
*
|
||||
* `ref` هو معرّف الإحالة، ويحرسه فهرس فريد جزئي على القاعدة
|
||||
* (`IDX_tripz_credit_txns_referral_once`) بنفس منطق J6: الماسح والمسار
|
||||
* الحدثي قد يصرفان معاً، والقيد وحده هو ما يمنع الصرف مرتين.
|
||||
*
|
||||
* يعيد `false` إذا كانت مصروفة سلفاً — فيعرف المنادي ألّا يعلّمها مدفوعة
|
||||
* بناءً على نجاحٍ لم يحدث.
|
||||
*/
|
||||
async grantReferralBonus(
|
||||
tenantId: string,
|
||||
driverId: string,
|
||||
amount: number,
|
||||
referralId: string,
|
||||
currency = 'JOD',
|
||||
): Promise<boolean> {
|
||||
if (!(amount > 0)) return false;
|
||||
|
||||
// حارسان مثل J6: فحص تطبيقي للحالة الشائعة (الماسح يعيد المحاولة)،
|
||||
// وفهرس فريد جزئي على القاعدة للسباق الحقيقي. الفحص وحده يسقط تحت
|
||||
// التزامن، والفهرس وحده يجعل الحالة الشائعة استثناءً — فكلاهما.
|
||||
const already = await this.txns.findOne({
|
||||
where: { tenant_id: tenantId, type: 'referral_bonus', ref: referralId },
|
||||
});
|
||||
if (already) return false;
|
||||
|
||||
try {
|
||||
await this.apply(tenantId, driverId, amount, 'referral_bonus', { ref: referralId }, currency);
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
if (e?.code === '23505' || e?.driverError?.code === '23505') {
|
||||
this.logger.debug(`referral bonus already granted for ${referralId}`);
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* خصم عمولة رحلة (docs/18 §5.2).
|
||||
* **لا يفشل أبداً** — ولو صار الرصيد سالباً. الرحلة تمّت فالعمولة استُحقّت؛
|
||||
|
||||
@@ -15,6 +15,7 @@ export type CreditTxnType =
|
||||
| 'topup' // شحن مدفوع — إيراد
|
||||
| 'signup_bonus' // مكافأة تسجيل — تكلفة تجنيد
|
||||
| 'promo_bonus' // حافز باقة («اشحن 50 خذ 55») — تكلفة تسويق
|
||||
| 'referral_bonus'// مكافأة إحالة — تكلفة تجنيد، منفصلة عن حافز الباقة
|
||||
| 'commission' // خصم عمولة رحلة
|
||||
| 'adjustment'; // تسوية يدوية من الأدمن
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { computeDiscount } from './coupons.service';
|
||||
import { resolveRewardsPolicy, DEFAULT_MAX_DISCOUNT } from './rewards-policy';
|
||||
|
||||
const coupon = (
|
||||
discount_type: 'percent' | 'flat',
|
||||
discount_value: number,
|
||||
max_discount: number | null = null,
|
||||
) => ({ discount_type, discount_value, max_discount }) as any;
|
||||
|
||||
describe('computeDiscount — سقف الكوبون (المجموعة L)', () => {
|
||||
it('نسبة مئوية بلا سقف تُطبَّق كما هي حين لا تبلغ الحدّ', () => {
|
||||
// 10% من 100 جنيه = 10، وسقف مصر 25 — فلا قصّ.
|
||||
const r = computeDiscount(coupon('percent', 10), { maxDiscount: 25 }, 100);
|
||||
expect(r.discount).toBe(10);
|
||||
expect(r.capped_by).toBeUndefined();
|
||||
});
|
||||
|
||||
it('**سقف السياسة يقصّ النسبة المئوية** — قرار المالك: 25 جنيهاً حدّاً أعلى', () => {
|
||||
// 50% من 300 جنيه = 150 — تُقصّ إلى 25. هذا هو البند المطلوب حرفياً:
|
||||
// كوبون نسبته كبيرة على أجرة كبيرة يجب ألّا يأكل الأجرة.
|
||||
const r = computeDiscount(coupon('percent', 50), { maxDiscount: 25 }, 300);
|
||||
expect(r.discount).toBe(25);
|
||||
expect(r.capped_by).toBe('policy');
|
||||
});
|
||||
|
||||
it('سقف السياسة يقصّ الخصم الثابت أيضاً — لا مهرب عبر نوع الخصم', () => {
|
||||
const r = computeDiscount(coupon('flat', 200), { maxDiscount: 25 }, 300);
|
||||
expect(r.discount).toBe(25);
|
||||
expect(r.capped_by).toBe('policy');
|
||||
});
|
||||
|
||||
it('سقف الكوبون الأدنى يتقدّم على سقف السياسة', () => {
|
||||
const r = computeDiscount(coupon('percent', 50, 10), { maxDiscount: 25 }, 300);
|
||||
expect(r.discount).toBe(10);
|
||||
expect(r.capped_by).toBe('coupon');
|
||||
});
|
||||
|
||||
it('**سقف كوبون أعلى من السياسة لا يرفع الحدّ** — السياسة حدّ مطلق', () => {
|
||||
// الثغرة الجوهرية: لو كان سقف الكوبون هو الحاكم، لَفتح أدمنٌ (أو إعداد
|
||||
// مكسور) خصماً بلا حدّ بمجرّد كتابة رقم كبير في حقل الكوبون.
|
||||
const r = computeDiscount(coupon('percent', 50, 999), { maxDiscount: 25 }, 300);
|
||||
expect(r.discount).toBe(25);
|
||||
expect(r.capped_by).toBe('policy');
|
||||
});
|
||||
|
||||
it('الخصم لا يتجاوز الأجرة — لا أجرة سالبة ولا دفعٌ للراكب', () => {
|
||||
const r = computeDiscount(coupon('flat', 25), { maxDiscount: 25 }, 8);
|
||||
expect(r.discount).toBe(8);
|
||||
expect(r.capped_by).toBe('fare');
|
||||
});
|
||||
|
||||
it('سقف صفر = لا خصم بهذه العملة (قرار مشروع لا قيمة مفقودة)', () => {
|
||||
const r = computeDiscount(coupon('percent', 50), { maxDiscount: 0 }, 300);
|
||||
expect(r.discount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRewardsPolicy — سقف كل عملة', () => {
|
||||
it('الافتراضات: 25 جنيهاً في مصر ومقابلها في الأردن وسوريا', () => {
|
||||
expect(resolveRewardsPolicy(null, 'EGP').maxDiscount).toBe(25);
|
||||
expect(resolveRewardsPolicy(null, 'SYP').maxDiscount).toBe(190);
|
||||
expect(resolveRewardsPolicy(null, 'JOD').maxDiscount).toBe(1.375);
|
||||
expect(DEFAULT_MAX_DISCOUNT.EGP).toBe(25);
|
||||
});
|
||||
|
||||
it('**السقوف متناسبة مع الحدود الدنيا** — لا نسبة داخلية قديمة', () => {
|
||||
// مرساة المالك: 1.10 دينار = 20 جنيهاً = 150 ليرة. السقف يجب أن يحافظ
|
||||
// على النسبة نفسها، وإلا صار العرض في بلدٍ سُدس ما هو عليه في آخر.
|
||||
const jo = resolveRewardsPolicy(null, 'JOD').maxDiscount / 1.1;
|
||||
const eg = resolveRewardsPolicy(null, 'EGP').maxDiscount / 20;
|
||||
const sy = resolveRewardsPolicy(null, 'SYP').maxDiscount / 150;
|
||||
expect(eg).toBeCloseTo(jo, 1);
|
||||
expect(sy).toBeCloseTo(jo, 1);
|
||||
});
|
||||
|
||||
it('إعداد المستأجر يتقدّم على الافتراض', () => {
|
||||
const s = { rewards: { max_discount: { EGP: 40 } } };
|
||||
expect(resolveRewardsPolicy(s, 'EGP').maxDiscount).toBe(40);
|
||||
});
|
||||
|
||||
it('سقف صفر إعدادٌ صالح — لا يعود إلى الافتراض (فخّ الصفر الصادق)', () => {
|
||||
const s = { rewards: { max_discount: { EGP: 0 } } };
|
||||
expect(resolveRewardsPolicy(s, 'EGP').maxDiscount).toBe(0);
|
||||
});
|
||||
|
||||
it('إعداد مكسور يسقط للافتراض — لا يفتح خصماً بلا سقف', () => {
|
||||
expect(resolveRewardsPolicy({ rewards: { max_discount: { EGP: 'كثير' } } }, 'EGP').maxDiscount)
|
||||
.toBe(25);
|
||||
expect(resolveRewardsPolicy({ rewards: { max_discount: { EGP: -5 } } }, 'EGP').maxDiscount)
|
||||
.toBe(25);
|
||||
});
|
||||
|
||||
it('عملة بلا سياسة = سقف صفر لا سقف مفتوح', () => {
|
||||
// الافتراض هو المنع: عملة أُضيفت ولم تُضبط سياستها يجب ألّا تعني
|
||||
// «اخصم بلا حدّ» — نفس منطق القائمة البيضاء في K2.
|
||||
expect(resolveRewardsPolicy(null, 'USD').maxDiscount).toBe(0);
|
||||
});
|
||||
|
||||
it('شرط التأهيل لا ينزل تحت رحلة واحدة ولو ضبطه الأدمن صفراً', () => {
|
||||
// 0 يعني الصرف عند التسجيل — وهو باب الاحتيال الذي بُني الحقل لإغلاقه.
|
||||
const s = { rewards: { qualifying_trips: 0 } };
|
||||
expect(resolveRewardsPolicy(s, 'JOD').qualifyingTrips).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Coupon } from './entities/coupon.entity';
|
||||
import { CouponRedemption } from './entities/coupon-redemption.entity';
|
||||
import { RewardsPolicy, resolveRewardsPolicy } from './rewards-policy';
|
||||
import { TenantsService } from '../tenants/tenants.service';
|
||||
|
||||
/** سبب رفض الكوبون — يُترجَم في الواجهة، فلا نص عربي هنا. */
|
||||
export type CouponRejection =
|
||||
| 'not_found'
|
||||
| 'disabled'
|
||||
| 'not_started'
|
||||
| 'expired'
|
||||
| 'currency_mismatch'
|
||||
| 'fare_too_low'
|
||||
| 'exhausted'
|
||||
| 'user_limit'
|
||||
| 'not_yours';
|
||||
|
||||
export interface CouponEvaluation {
|
||||
ok: boolean;
|
||||
reason?: CouponRejection;
|
||||
coupon?: Coupon;
|
||||
/** الخصم بعد تطبيق السقف — هذا ما يُخصم فعلاً، لا القيمة الاسمية. */
|
||||
discount: number;
|
||||
/** الحدّ الذي قصّ الخصم، إن قُصّ — لتفسير «ليش خصمي أقل من الكوبون؟». */
|
||||
capped_by?: 'coupon' | 'policy' | 'fare';
|
||||
}
|
||||
|
||||
/**
|
||||
* يحسب خصم كوبون على أجرة — **دالة صافية** لا قاعدة ولا وقت، لتُختبر وحدها.
|
||||
*
|
||||
* ترتيب القصّ مقصود: القيمة الاسمية ← سقف الكوبون ← **سقف السياسة** ←
|
||||
* الأجرة نفسها. سقف السياسة بعد سقف الكوبون لا قبله، فهو حدّ أعلى مطلق
|
||||
* لا افتراض: كوبون يعلن سقفاً أعلى لا يرفع الحدّ، والأدنى دائماً هو الحاكم.
|
||||
* وقصّ الأجرة أخيراً يمنع أجرةً سالبة (خصم أكبر من الأجرة = دفعٌ للراكب).
|
||||
*/
|
||||
export function computeDiscount(
|
||||
coupon: Pick<Coupon, 'discount_type' | 'discount_value' | 'max_discount'>,
|
||||
policy: Pick<RewardsPolicy, 'maxDiscount'>,
|
||||
fare: number,
|
||||
): { discount: number; capped_by?: 'coupon' | 'policy' | 'fare' } {
|
||||
const value = Number(coupon.discount_value) || 0;
|
||||
let discount =
|
||||
coupon.discount_type === 'percent' ? (fare * value) / 100 : value;
|
||||
let capped_by: 'coupon' | 'policy' | 'fare' | undefined;
|
||||
|
||||
const couponCap = coupon.max_discount == null ? null : Number(coupon.max_discount);
|
||||
if (couponCap != null && discount > couponCap) {
|
||||
discount = couponCap;
|
||||
capped_by = 'coupon';
|
||||
}
|
||||
const policyCap = Number(policy.maxDiscount);
|
||||
// `> 0` لا `!= null`: سقف صفر يعني «لا خصم بهذه العملة» وهو قرار مشروع،
|
||||
// بينما تجاهله كان سيفتح الخصم بلا حدّ في عملة لم تُضبط سياستها بعد.
|
||||
if (Number.isFinite(policyCap) && discount > policyCap) {
|
||||
discount = policyCap;
|
||||
capped_by = 'policy';
|
||||
}
|
||||
if (discount > fare) {
|
||||
discount = fare;
|
||||
capped_by = 'fare';
|
||||
}
|
||||
if (!(discount > 0)) return { discount: 0, capped_by };
|
||||
return { discount: Number(discount.toFixed(3)), capped_by };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CouponsService {
|
||||
private readonly logger = new Logger('Coupons');
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Coupon) private readonly coupons: Repository<Coupon>,
|
||||
@InjectRepository(CouponRedemption)
|
||||
private readonly redemptions: Repository<CouponRedemption>,
|
||||
private readonly tenants: TenantsService,
|
||||
) {}
|
||||
|
||||
/** سياسة المكافآت لعملةٍ ما — عبر كاش المستأجر القائم (نمط `credit.policy`). */
|
||||
async policy(tenantId: string, currency = 'JOD'): Promise<RewardsPolicy> {
|
||||
const t = await this.tenants.resolve(tenantId);
|
||||
return resolveRewardsPolicy(t?.settings, currency);
|
||||
}
|
||||
|
||||
/** التطبيع: الكود يُملى صوتياً ويُكتب بأي حالة — المطابقة تفشل بدونه. */
|
||||
static normalize(code: string): string {
|
||||
return (code ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* يقيّم كوبوناً بلا أي أثر — للاقتباس («كم سأدفع؟») قبل إنشاء الرحلة.
|
||||
* لا يستهلك شيئاً: الاستهلاك في `redeem` عند تسوية الأجرة وحدها.
|
||||
*/
|
||||
async evaluate(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
code: string,
|
||||
fare: number,
|
||||
currency = 'JOD',
|
||||
now: Date = new Date(),
|
||||
): Promise<CouponEvaluation> {
|
||||
const coupon = await this.coupons.findOne({
|
||||
where: { tenant_id: tenantId, code: CouponsService.normalize(code) },
|
||||
});
|
||||
if (!coupon) return { ok: false, reason: 'not_found', discount: 0 };
|
||||
|
||||
const reject = (reason: CouponRejection): CouponEvaluation => ({
|
||||
ok: false,
|
||||
reason,
|
||||
coupon,
|
||||
discount: 0,
|
||||
});
|
||||
|
||||
if (coupon.status !== 'active') return reject('disabled');
|
||||
// كوبون مُوجَّه لشخص: لا يستعمله غيره ولو عرف الكود.
|
||||
if (coupon.user_id && coupon.user_id !== userId) return reject('not_yours');
|
||||
if (coupon.valid_from && now < new Date(coupon.valid_from)) return reject('not_started');
|
||||
if (coupon.valid_until && now > new Date(coupon.valid_until)) return reject('expired');
|
||||
if (coupon.currency !== currency) return reject('currency_mismatch');
|
||||
if (coupon.min_fare != null && fare < Number(coupon.min_fare)) return reject('fare_too_low');
|
||||
if (
|
||||
coupon.max_redemptions != null &&
|
||||
coupon.redeemed_count >= coupon.max_redemptions
|
||||
) {
|
||||
return reject('exhausted');
|
||||
}
|
||||
|
||||
const used = await this.redemptions.count({
|
||||
where: { tenant_id: tenantId, coupon_id: coupon.id, user_id: userId },
|
||||
});
|
||||
if (used >= coupon.max_per_user) return reject('user_limit');
|
||||
|
||||
const policy = await this.policy(tenantId, currency);
|
||||
const { discount, capped_by } = computeDiscount(coupon, policy, fare);
|
||||
if (!(discount > 0)) return reject('fare_too_low');
|
||||
|
||||
return { ok: true, coupon, discount, capped_by };
|
||||
}
|
||||
|
||||
/**
|
||||
* يستهلك الكوبون على رحلة ويكتب الدفتر. يُنادى **مرة واحدة** عند تسوية
|
||||
* الأجرة، لا عند الاقتباس: الاقتباس يتكرر مع كل تغيير وجهة.
|
||||
*
|
||||
* يعيد الخصم المطبَّق فعلاً، أو 0 إذا سقط الكوبون بين الاقتباس والتسوية
|
||||
* (نفد · انتهى · استُعمل على رحلة أخرى). **لا يرمي في المسار العادي**:
|
||||
* الرحلة تمّت، ورفض تسويتها لأجل كوبون يعني رحلةً معلّقة بلا أجرة.
|
||||
*/
|
||||
async redeem(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
code: string,
|
||||
tripId: string,
|
||||
fare: number,
|
||||
currency = 'JOD',
|
||||
): Promise<number> {
|
||||
const evaluation = await this.evaluate(tenantId, userId, code, fare, currency);
|
||||
if (!evaluation.ok || !evaluation.coupon) {
|
||||
this.logger.warn(
|
||||
`coupon ${CouponsService.normalize(code)} rejected at settle for trip ${tripId}: ${evaluation.reason}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const coupon = evaluation.coupon;
|
||||
|
||||
// الحجز ذرّي على القاعدة: الشرط داخل الـUPDATE لا قبله، فطلبان متزامنان
|
||||
// على آخر استعمال متبقٍّ لا ينجحان معاً (نمط I1).
|
||||
const res = await this.coupons
|
||||
.createQueryBuilder()
|
||||
.update(Coupon)
|
||||
.set({ redeemed_count: () => 'redeemed_count + 1' })
|
||||
.where('id = :id AND tenant_id = :tenant AND status = :active', {
|
||||
id: coupon.id,
|
||||
tenant: tenantId,
|
||||
active: 'active',
|
||||
})
|
||||
.andWhere('(max_redemptions IS NULL OR redeemed_count < max_redemptions)')
|
||||
.execute();
|
||||
if (!res.affected) {
|
||||
this.logger.warn(`coupon ${coupon.code} exhausted mid-settle for trip ${tripId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.redemptions.insert({
|
||||
tenant_id: tenantId,
|
||||
coupon_id: coupon.id,
|
||||
user_id: userId,
|
||||
trip_id: tripId,
|
||||
discount_amount: evaluation.discount,
|
||||
fare_before: fare,
|
||||
currency,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// الفريد على (tenant, trip) صدّ محاولة خصم ثانية على نفس الرحلة —
|
||||
// نُرجع الحجز حتى لا يضيع استعمالٌ على لا شيء.
|
||||
await this.coupons
|
||||
.createQueryBuilder()
|
||||
.update(Coupon)
|
||||
.set({ redeemed_count: () => 'GREATEST(redeemed_count - 1, 0)' })
|
||||
.where('id = :id', { id: coupon.id })
|
||||
.execute();
|
||||
this.logger.warn(`coupon redemption rejected for trip ${tripId}: ${e?.message}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return evaluation.discount;
|
||||
}
|
||||
|
||||
/**
|
||||
* يمنح كوبوناً مُوجَّهاً لمستخدم (مكافأة إحالة أو تعويض دعم).
|
||||
* القيمة تُقصّ بسقف السياسة **عند المنح أيضاً** لا عند الاستعمال وحده:
|
||||
* كوبون معروض بقيمة لن تُصرف كاملةً وعدٌ مكسور للمستخدم.
|
||||
*/
|
||||
async grant(params: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
source: string;
|
||||
validDays: number;
|
||||
codePrefix?: string;
|
||||
/** سبب المنح — يجعل العملية idempotent عبر الفهرس الفريد الجزئي. */
|
||||
ref?: string;
|
||||
}): Promise<Coupon | null> {
|
||||
const policy = await this.policy(params.tenantId, params.currency);
|
||||
const amount = Math.min(Number(params.amount), policy.maxDiscount);
|
||||
if (!(amount > 0)) throw new BadRequestException('coupon amount must be > 0');
|
||||
|
||||
// فحص مبكر للحالة الشائعة (إعادة محاولة الماسح) — القيد هو الحارس الفعلي.
|
||||
if (params.ref) {
|
||||
const already = await this.coupons.findOne({
|
||||
where: { tenant_id: params.tenantId, ref: params.ref },
|
||||
});
|
||||
if (already) return already;
|
||||
}
|
||||
|
||||
const validUntil = new Date();
|
||||
validUntil.setDate(validUntil.getDate() + params.validDays);
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const code = `${params.codePrefix ?? 'GIFT'}${randomCode(6)}`;
|
||||
try {
|
||||
return await this.coupons.save(
|
||||
this.coupons.create({
|
||||
tenant_id: params.tenantId,
|
||||
code,
|
||||
user_id: params.userId,
|
||||
discount_type: 'flat',
|
||||
discount_value: amount,
|
||||
max_discount: amount,
|
||||
currency: params.currency,
|
||||
valid_until: validUntil,
|
||||
max_redemptions: 1,
|
||||
max_per_user: 1,
|
||||
source: params.source,
|
||||
ref: params.ref ?? null,
|
||||
}),
|
||||
);
|
||||
} catch (e: any) {
|
||||
const dup = e?.code === '23505' || e?.driverError?.code === '23505';
|
||||
// التصادم إمّا على الكود العشوائي (نعيد التوليد) أو على `ref` — وهذا
|
||||
// يعني أن نداءً متزامناً سبقنا للمكافأة نفسها، فلا نمنح ثانية.
|
||||
if (dup && params.ref) {
|
||||
const existing = await this.coupons.findOne({
|
||||
where: { tenant_id: params.tenantId, ref: params.ref },
|
||||
});
|
||||
if (existing) return existing;
|
||||
}
|
||||
if (attempt === 4) throw e;
|
||||
}
|
||||
}
|
||||
throw new BadRequestException('could not allocate coupon code');
|
||||
}
|
||||
|
||||
/**
|
||||
* كود حملة عام يكتبه أي راكب. لا `user_id` — بخلاف كوبون الإحالة.
|
||||
*
|
||||
* `max_redemptions` غير المضبوط يعني حملة مفتوحة، وهو خيار مشروع لكنه
|
||||
* مكلف: نسجّله تحذيراً حتى لا يمرّ سهواً في إعداد أدمن.
|
||||
*/
|
||||
async createCampaign(
|
||||
tenantId: string,
|
||||
body: {
|
||||
code: string;
|
||||
discount_type?: 'percent' | 'flat';
|
||||
discount_value: number;
|
||||
max_discount?: number;
|
||||
min_fare?: number;
|
||||
currency?: string;
|
||||
valid_until?: string;
|
||||
max_redemptions?: number;
|
||||
max_per_user?: number;
|
||||
},
|
||||
): Promise<Coupon> {
|
||||
const code = CouponsService.normalize(body.code);
|
||||
if (!code) throw new BadRequestException('code is required');
|
||||
const value = Number(body.discount_value);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new BadRequestException('discount_value must be >= 0');
|
||||
}
|
||||
const type = body.discount_type ?? 'percent';
|
||||
if (type === 'percent' && value > 100) {
|
||||
throw new BadRequestException('percent discount cannot exceed 100');
|
||||
}
|
||||
if (body.max_redemptions == null) {
|
||||
this.logger.warn(`campaign coupon ${code} created with no global redemption limit`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.coupons.save(
|
||||
this.coupons.create({
|
||||
tenant_id: tenantId,
|
||||
code,
|
||||
user_id: null,
|
||||
discount_type: type,
|
||||
discount_value: value,
|
||||
max_discount: body.max_discount ?? null,
|
||||
min_fare: body.min_fare ?? null,
|
||||
currency: body.currency ?? 'JOD',
|
||||
valid_until: body.valid_until ? new Date(body.valid_until) : null,
|
||||
max_redemptions: body.max_redemptions ?? null,
|
||||
max_per_user: body.max_per_user ?? 1,
|
||||
source: 'campaign',
|
||||
}),
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e?.code === '23505' || e?.driverError?.code === '23505') {
|
||||
throw new BadRequestException(`coupon code ${code} already exists`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** كوبونات المستخدم الصالحة الآن — لشاشة «مكافآتي». */
|
||||
async listMine(tenantId: string, userId: string): Promise<Coupon[]> {
|
||||
const all = await this.coupons.find({
|
||||
where: { tenant_id: tenantId, user_id: userId },
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
const now = new Date();
|
||||
return all.filter(
|
||||
(c) =>
|
||||
c.status === 'active' &&
|
||||
(c.max_redemptions == null || c.redeemed_count < c.max_redemptions) &&
|
||||
(!c.valid_until || new Date(c.valid_until) >= now),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** بلا 0/O/1/I/L — الكود يُملى صوتياً وهذه الأحرف تُسمع سواءً. */
|
||||
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
|
||||
export function randomCode(len: number): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < len; i++) {
|
||||
out += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* دفتر استهلاك الكوبونات. الجدول: tripz_coupon_redemptions. append-only.
|
||||
*
|
||||
* هو مصدر الحقيقة لكم أُنفق تسويقياً — `coupons.redeemed_count` حارس تزامن
|
||||
* فقط. الفريد على `(tenant_id, trip_id)` يمنع خصم كوبونين على رحلة واحدة.
|
||||
*/
|
||||
@Entity('coupon_redemptions')
|
||||
@Index(['tenant_id', 'coupon_id'])
|
||||
@Index(['tenant_id', 'user_id'])
|
||||
export class CouponRedemption {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
coupon_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
user_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
trip_id: string;
|
||||
|
||||
/** الخصم المطبَّق فعلاً — بعد السقف، لا القيمة الاسمية للكوبون. */
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3 })
|
||||
discount_amount: number;
|
||||
|
||||
/** الأجرة قبل الخصم — بدونها لا يُقرأ أثر الحملة من الدفتر وحده. */
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
fare_before: number | null;
|
||||
|
||||
@Column({ type: 'varchar', default: 'JOD' })
|
||||
currency: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export type CouponDiscountType = 'percent' | 'flat';
|
||||
|
||||
/**
|
||||
* كوبون خصم على أجرة الراكب. الجدول: tripz_coupons.
|
||||
*
|
||||
* الخصم يمسّ `price_for_passenger` وحده — `price_for_driver` يبقى كاملاً
|
||||
* (docs/18 §52). فالكوبون تكلفة تسويق على المنصة، لا اقتطاع من دخل السائق.
|
||||
*
|
||||
* الكوبون كيان واحد لحالتين: **كود عام** يكتبه الراكب (`user_id = null`)،
|
||||
* و**كوبون مُوجَّه** يُمنح لشخص بعينه كمكافأة إحالة (`user_id` مضبوط).
|
||||
* فصلهما لجدولين كان سيكرّر منطق السقف والصلاحية والاستهلاك حرفياً.
|
||||
*/
|
||||
@Entity('coupons')
|
||||
@Index(['tenant_id', 'code'], { unique: true })
|
||||
@Index(['tenant_id', 'user_id'])
|
||||
export class Coupon {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
/** يُخزَّن دائماً بحروف كبيرة — المطابقة حساسة لولا التطبيع. */
|
||||
@Column({ type: 'varchar' })
|
||||
code: string;
|
||||
|
||||
/** مالك الكوبون؛ `null` = كود عام متاح للجميع. */
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
user_id: string | null;
|
||||
|
||||
@Column({ type: 'varchar', default: 'percent' })
|
||||
discount_type: CouponDiscountType;
|
||||
|
||||
/** نسبة مئوية (0–100) أو مبلغ ثابت بحسب `discount_type`. */
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3 })
|
||||
discount_value: number;
|
||||
|
||||
/**
|
||||
* سقف خصم هذا الكوبون. `null` = لا سقف خاص، فيُطبَّق سقف سياسة المستأجر.
|
||||
* وفي كل الأحوال **لا يتجاوز الخصمُ سقفَ السياسة** ولو كان هذا أعلى:
|
||||
* السقف الأدنى من الاثنين هو الحاكم (`rewards-policy.ts`).
|
||||
*/
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
max_discount: number | null;
|
||||
|
||||
/** لا يُطبَّق الكوبون على أجرة أقل من هذا — يمنع استهلاكه على رحلة رمزية. */
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
min_fare: number | null;
|
||||
|
||||
@Column({ type: 'varchar', default: 'JOD' })
|
||||
currency: string;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
valid_from: Date | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
valid_until: Date | null;
|
||||
|
||||
/** أقصى استهلاك إجمالي للكود كله؛ `null` = بلا حدّ. */
|
||||
@Column({ type: 'int', nullable: true })
|
||||
max_redemptions: number | null;
|
||||
|
||||
/** أقصى استهلاك للراكب الواحد من هذا الكود. */
|
||||
@Column({ type: 'int', default: 1 })
|
||||
max_per_user: number;
|
||||
|
||||
/**
|
||||
* عدّاد الاستهلاك — يُزاد بـ`UPDATE … WHERE redeemed < max` الشرطي، فلا
|
||||
* يتجاوز طلبان متزامنان الحدَّ معاً. المصدر النهائي للحقيقة يبقى دفتر
|
||||
* `coupon_redemptions`؛ هذا العمود حارس تزامن لا محاسبة.
|
||||
*/
|
||||
@Column({ type: 'int', default: 0 })
|
||||
redeemed_count: number;
|
||||
|
||||
/** active | disabled — الإلغاء تعطيل لا حذف، حفاظاً على دفتر الاستهلاك. */
|
||||
@Column({ type: 'varchar', default: 'active' })
|
||||
status: string;
|
||||
|
||||
/** سبب المنح: `referral` · `campaign` · `support` — للتقارير التسويقية. */
|
||||
@Column({ type: 'varchar', default: 'campaign' })
|
||||
source: string;
|
||||
|
||||
/**
|
||||
* مرجع سبب المنح (معرّف الإحالة مثلاً) — يحرسه فهرس فريد جزئي فلا يُمنح
|
||||
* كوبونان لسببٍ واحد. نظير الحارس على دفتر الرصيد تماماً: الصرف الفوري
|
||||
* والماسح قد يلتقيان على نفس الإحالة، والقيد وحده يمنع مكافأة مزدوجة.
|
||||
*/
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
ref: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* كود الدعوة الشخصي لكل مستخدم. الجدول: tripz_referral_codes.
|
||||
*
|
||||
* جدول مستقلّ لا عمود على `users`: الكود يحتاج فريداً على `(tenant_id, code)`
|
||||
* وتعطيلاً وتدويراً، وثلاثتها أعمدة لا محلّ لها في جدول الهوية. ولأن
|
||||
* `users` مشفّر الحقول، إضافة فهرس بحث جديد عليه ثمنها أعلى من جدول صغير.
|
||||
*/
|
||||
@Entity('referral_codes')
|
||||
@Index(['tenant_id', 'code'], { unique: true })
|
||||
@Index(['tenant_id', 'user_id'], { unique: true })
|
||||
export class ReferralCode {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
user_id: string;
|
||||
|
||||
/** بحروف كبيرة دائماً، بلا أحرف ملتبسة (0/O · 1/I) — يُملى صوتياً كثيراً. */
|
||||
@Column({ type: 'varchar' })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
role: 'rider' | 'driver';
|
||||
|
||||
@Column({ type: 'varchar', default: 'active' })
|
||||
status: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* `pending` = سُجّلت الإحالة، والمُحال لم يستوفِ الشرط بعد.
|
||||
* `qualified` = استوفى (أتمّ رحلاته المطلوبة) ولمّا تُصرف المكافأة.
|
||||
* `paid` = صُرفت للطرفين.
|
||||
* `rejected` = رُفضت (احتيال) — لا تُحذف، فالسجل دليل عند المراجعة.
|
||||
*/
|
||||
export type ReferralStatus = 'pending' | 'qualified' | 'paid' | 'rejected';
|
||||
|
||||
export type ReferralParty = 'rider' | 'driver';
|
||||
|
||||
/**
|
||||
* إحالة واحدة: من دعا مَن. الجدول: tripz_referrals.
|
||||
*
|
||||
* الاتجاهات الأربعة (راكب→راكب · راكب→سائق · سائق→راكب · سائق→سائق) ليست
|
||||
* أربعة جداول بل عمودا دور: شكل المكافأة هو ما يختلف لا شكل العلاقة —
|
||||
* السائق يُكافأ رصيداً تشغيلياً، والراكب كوبوناً (محفظته لا تُسحب).
|
||||
*
|
||||
* **الفريد على `(tenant_id, referee_user_id)` هو الحارس الأساسي**: الشخص
|
||||
* يُحال مرة واحدة في عمره. بدونه يُعاد تسجيل نفس الضحية تحت عشرة داعين.
|
||||
*/
|
||||
@Entity('referrals')
|
||||
// الفريد معلَن على الكيان أيضاً لا في الهجرة وحدها: بيئة تُبنى بـ`synchronize`
|
||||
// (الاختبارات) كانت ستفقد الحارس بالضبط حيث نظنّ أننا نختبره.
|
||||
@Index(['tenant_id', 'referee_user_id'], { unique: true })
|
||||
@Index(['tenant_id', 'referrer_user_id'])
|
||||
@Index(['tenant_id', 'status'])
|
||||
export class Referral {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
referrer_user_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
referee_user_id: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
referrer_role: ReferralParty;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
referee_role: ReferralParty;
|
||||
|
||||
/** الكود المستعمل وقت التسجيل — يُحفظ نصاً فلا يضيع المعنى لو حُذف الكود. */
|
||||
@Column({ type: 'varchar' })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar', default: 'pending' })
|
||||
status: ReferralStatus;
|
||||
|
||||
/** كم رحلة مؤهِّلة أتمّها المُحال حتى الآن. */
|
||||
@Column({ type: 'int', default: 0 })
|
||||
qualifying_trips: number;
|
||||
|
||||
/** الرحلة التي أكملت الشرط — إثبات الاستحقاق عند أي نزاع. */
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
qualified_trip_id: string | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
qualified_at: Date | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
paid_at: Date | null;
|
||||
|
||||
/** سبب الرفض عند `rejected` — بلا سبب مكتوب لا تُراجَع القرارات. */
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
rejected_reason: string | null;
|
||||
|
||||
@Column({ type: 'varchar', default: 'JOD' })
|
||||
currency: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { newDb } from 'pg-mem';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Coupon } from './entities/coupon.entity';
|
||||
import { CouponRedemption } from './entities/coupon-redemption.entity';
|
||||
import { Referral } from './entities/referral.entity';
|
||||
import { ReferralCode } from './entities/referral-code.entity';
|
||||
import { CouponsService } from './coupons.service';
|
||||
import { ReferralsService } from './referrals.service';
|
||||
import { DriverCredit } from '../credit/entities/driver-credit.entity';
|
||||
import { CreditTxn } from '../credit/entities/credit-txn.entity';
|
||||
import { DriverCreditService } from '../credit/driver-credit.service';
|
||||
|
||||
const TENANT = '11111111-1111-1111-1111-111111111111';
|
||||
const REFERRER = '22222222-2222-2222-2222-222222222222';
|
||||
const REFEREE = '33333333-3333-3333-3333-333333333333';
|
||||
const DRIVER_ROW = '44444444-4444-4444-4444-444444444444';
|
||||
const TRIP = '55555555-5555-5555-5555-555555555555';
|
||||
|
||||
const fakeTenants = (settings: Record<string, any> = {}) =>
|
||||
({ resolve: jest.fn().mockResolvedValue({ id: TENANT, settings }) }) as any;
|
||||
|
||||
/** `drivers.findByUser` هو ما يحسم شكل المكافأة — نتحكّم به لكل حالة. */
|
||||
const fakeDrivers = (driverUserIds: string[] = []) =>
|
||||
({
|
||||
findByUser: jest.fn(async (_t: string, userId: string) =>
|
||||
driverUserIds.includes(userId) ? { id: DRIVER_ROW, user_id: userId } : null,
|
||||
),
|
||||
}) as any;
|
||||
|
||||
describe('ReferralsService — الإحالة والمكافآت (المجموعة L)', () => {
|
||||
let ds: DataSource;
|
||||
|
||||
async function build(settings: Record<string, any>, driverUserIds: string[] = []) {
|
||||
const db = newDb({ autoCreateForeignKeyIndices: true });
|
||||
db.public.registerFunction({
|
||||
name: 'version',
|
||||
returns: 'text' as any,
|
||||
implementation: () => 'pg-mem',
|
||||
});
|
||||
db.public.registerFunction({
|
||||
name: 'current_database',
|
||||
returns: 'text' as any,
|
||||
implementation: () => 'tripz',
|
||||
});
|
||||
db.registerExtension('uuid-ossp', (schema) =>
|
||||
schema.registerFunction({
|
||||
name: 'uuid_generate_v4',
|
||||
returns: 'uuid' as any,
|
||||
implementation: () => randomUUID(),
|
||||
impure: true,
|
||||
}),
|
||||
);
|
||||
await db.public.none(`CREATE EXTENSION "uuid-ossp"`);
|
||||
|
||||
ds = (await db.adapters.createTypeormDataSource({
|
||||
type: 'postgres',
|
||||
entities: [Coupon, CouponRedemption, Referral, ReferralCode, DriverCredit, CreditTxn],
|
||||
entityPrefix: 'tripz_',
|
||||
})) as DataSource;
|
||||
await ds.initialize();
|
||||
await ds.synchronize();
|
||||
|
||||
const tenants = fakeTenants(settings);
|
||||
const coupons = new CouponsService(
|
||||
ds.getRepository(Coupon),
|
||||
ds.getRepository(CouponRedemption),
|
||||
tenants,
|
||||
);
|
||||
const credit = new DriverCreditService(
|
||||
ds.getRepository(DriverCredit),
|
||||
ds.getRepository(CreditTxn),
|
||||
tenants,
|
||||
);
|
||||
const drivers = fakeDrivers(driverUserIds);
|
||||
const referrals = new ReferralsService(
|
||||
ds.getRepository(Referral),
|
||||
ds.getRepository(ReferralCode),
|
||||
coupons,
|
||||
credit,
|
||||
drivers,
|
||||
tenants,
|
||||
);
|
||||
return { referrals, coupons, credit, ds };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (ds?.isInitialized) await ds.destroy();
|
||||
});
|
||||
|
||||
it('لا تُصرف مكافأة عند التسجيل — الرحلة المكتملة هي الشرط', async () => {
|
||||
const { referrals, coupons } = await build({});
|
||||
const code = await referrals.myCode(TENANT, REFERRER, 'rider');
|
||||
const referral = await referrals.register(TENANT, REFEREE, 'rider', code.code);
|
||||
|
||||
expect(referral!.status).toBe('pending');
|
||||
// الثغرة التي يغلقها هذا الشرط: خمسون رقماً وهمياً = خمسون مكافأة.
|
||||
expect(await coupons.listMine(TENANT, REFERRER)).toHaveLength(0);
|
||||
expect(await coupons.listMine(TENANT, REFEREE)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('الراكب يُكافأ **كوبوناً** لا رصيد محفظة — والمحفظة لا تُمسّ إطلاقاً', async () => {
|
||||
const { referrals, coupons } = await build({});
|
||||
const code = await referrals.myCode(TENANT, REFERRER, 'rider');
|
||||
await referrals.register(TENANT, REFEREE, 'rider', code.code);
|
||||
|
||||
await referrals.onTripCompleted(TENANT, REFEREE, TRIP);
|
||||
|
||||
const forReferrer = await coupons.listMine(TENANT, REFERRER);
|
||||
const forReferee = await coupons.listMine(TENANT, REFEREE);
|
||||
expect(forReferrer).toHaveLength(1);
|
||||
expect(forReferee).toHaveLength(1);
|
||||
// القانون الثابت: محفظة الراكب لا تُسحب، فالمكافأة كوبون مسقوف لا نقد.
|
||||
expect(Number(forReferrer[0].discount_value)).toBe(1); // DEFAULT_RIDER_REFERRAL_COUPON.JOD
|
||||
expect(forReferrer[0].source).toBe('referral');
|
||||
expect(forReferrer[0].max_discount).not.toBeNull();
|
||||
});
|
||||
|
||||
it('السائق يُكافأ **رصيداً تشغيلياً** — شكل المكافأة يتبع الدور', async () => {
|
||||
const { referrals, credit, coupons } = await build({}, [REFEREE]);
|
||||
const code = await referrals.myCode(TENANT, REFERRER, 'rider');
|
||||
await referrals.register(TENANT, REFEREE, 'driver', code.code);
|
||||
|
||||
await referrals.onTripCompleted(TENANT, REFEREE, TRIP);
|
||||
|
||||
const balance = await credit.get(TENANT, DRIVER_ROW);
|
||||
expect(Number(balance.balance)).toBe(1); // DEFAULT_DRIVER_REFERRAL_BONUS.JOD
|
||||
// المُحال سائق فلا كوبون له؛ والداعي راكب فله كوبون — الاتجاه المختلط.
|
||||
expect(await coupons.listMine(TENANT, REFEREE)).toHaveLength(0);
|
||||
expect(await coupons.listMine(TENANT, REFERRER)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('**الصرف مرتين ممنوع** — صرفٌ ثانٍ لا يمنح شيئاً إضافياً', async () => {
|
||||
const { referrals, coupons, credit } = await build({}, [REFERRER]);
|
||||
const code = await referrals.myCode(TENANT, REFERRER, 'driver');
|
||||
await referrals.register(TENANT, REFEREE, 'rider', code.code);
|
||||
await referrals.onTripCompleted(TENANT, REFEREE, TRIP);
|
||||
|
||||
const before = await credit.get(TENANT, DRIVER_ROW);
|
||||
// محاكاة التقاء الماسح بالصرف الفوري على نفس الإحالة.
|
||||
const qualified = await ds.getRepository(Referral).findOneOrFail({ where: {} });
|
||||
await referrals.payout({ ...qualified, status: 'qualified' } as any);
|
||||
|
||||
expect(Number((await credit.get(TENANT, DRIVER_ROW)).balance)).toBe(Number(before.balance));
|
||||
expect(await coupons.listMine(TENANT, REFEREE)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('الشخص يُحال **مرة واحدة** — داعٍ ثانٍ يُرفض', async () => {
|
||||
const { referrals } = await build({});
|
||||
const first = await referrals.myCode(TENANT, REFERRER, 'rider');
|
||||
const other = '66666666-6666-6666-6666-666666666666';
|
||||
const second = await referrals.myCode(TENANT, other, 'rider');
|
||||
|
||||
expect(await referrals.register(TENANT, REFEREE, 'rider', first.code)).not.toBeNull();
|
||||
// بدون هذا الحارس يُعاد تسجيل نفس الضحية تحت عشرة داعين.
|
||||
expect(await referrals.register(TENANT, REFEREE, 'rider', second.code)).toBeNull();
|
||||
});
|
||||
|
||||
it('إحالة النفس مرفوضة', async () => {
|
||||
const { referrals } = await build({});
|
||||
const code = await referrals.myCode(TENANT, REFERRER, 'rider');
|
||||
expect(await referrals.register(TENANT, REFERRER, 'rider', code.code)).toBeNull();
|
||||
});
|
||||
|
||||
it('كود مجهول لا يرمي — التسجيل يجب ألّا يُمنع بسبب كود خاطئ', async () => {
|
||||
const { referrals } = await build({});
|
||||
await expect(referrals.register(TENANT, REFEREE, 'rider', 'NOPE99')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('شرط رحلتين: الأولى لا تؤهّل والثانية تؤهّل وتصرف', async () => {
|
||||
const { referrals, coupons } = await build({ rewards: { qualifying_trips: 2 } });
|
||||
const code = await referrals.myCode(TENANT, REFERRER, 'rider');
|
||||
await referrals.register(TENANT, REFEREE, 'rider', code.code);
|
||||
|
||||
await referrals.onTripCompleted(TENANT, REFEREE, TRIP);
|
||||
expect(await coupons.listMine(TENANT, REFERRER)).toHaveLength(0);
|
||||
|
||||
await referrals.onTripCompleted(TENANT, REFEREE, TRIP);
|
||||
expect(await coupons.listMine(TENANT, REFERRER)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('كود الدعوة ثابت — نداء ثانٍ يعيد نفس الكود لا كوداً جديداً', async () => {
|
||||
const { referrals } = await build({});
|
||||
const a = await referrals.myCode(TENANT, REFERRER, 'rider');
|
||||
const b = await referrals.myCode(TENANT, REFERRER, 'rider');
|
||||
expect(b.code).toBe(a.code);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Referral, ReferralParty } from './entities/referral.entity';
|
||||
import { ReferralCode } from './entities/referral-code.entity';
|
||||
import { CouponsService, randomCode } from './coupons.service';
|
||||
import { resolveRewardsPolicy, RewardsPolicy } from './rewards-policy';
|
||||
import { TenantsService } from '../tenants/tenants.service';
|
||||
import { DriverCreditService } from '../credit/driver-credit.service';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
|
||||
/**
|
||||
* نظام الإحالة بالاتجاهات الأربعة (المجموعة L).
|
||||
*
|
||||
* شكل المكافأة يتبع **دور المستفيد** لا اتجاه الإحالة:
|
||||
* - سائق → رصيد تشغيلي (`referral_bonus`).
|
||||
* - راكب → كوبون خصم مسقوف. **لا رصيد محفظة**: محفظة الراكب لا تُسحب
|
||||
* (قانون ثابت)، فمكافأة تسويقية فيها تصير التزاماً نقدياً بلا مقابل.
|
||||
*
|
||||
* والاستحقاق **بعد رحلة مكتملة** لا عند التسجيل — بدون هذا الشرط خمسون
|
||||
* رقماً وهمياً تساوي خمسين مكافأة، وهو بالضبط ما يأكل ميزانية التسويق.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReferralsService {
|
||||
private readonly logger = new Logger('Referrals');
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Referral) private readonly referrals: Repository<Referral>,
|
||||
@InjectRepository(ReferralCode) private readonly codes: Repository<ReferralCode>,
|
||||
private readonly coupons: CouponsService,
|
||||
private readonly credit: DriverCreditService,
|
||||
private readonly drivers: DriversService,
|
||||
private readonly tenants: TenantsService,
|
||||
) {}
|
||||
|
||||
async policy(tenantId: string, currency = 'JOD'): Promise<RewardsPolicy> {
|
||||
const t = await this.tenants.resolve(tenantId);
|
||||
return resolveRewardsPolicy(t?.settings, currency);
|
||||
}
|
||||
|
||||
/** كود الدعوة الشخصي — يُولَّد عند أول طلب ويثبت بعدها. */
|
||||
async myCode(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
role: ReferralParty,
|
||||
): Promise<ReferralCode> {
|
||||
const existing = await this.codes.findOne({
|
||||
where: { tenant_id: tenantId, user_id: userId },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
return await this.codes.save(
|
||||
this.codes.create({
|
||||
tenant_id: tenantId,
|
||||
user_id: userId,
|
||||
code: randomCode(6),
|
||||
role,
|
||||
}),
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e?.code !== '23505' && e?.driverError?.code !== '23505') throw e;
|
||||
// إمّا تصادم كود (نعيد التوليد) أو نداءان متزامنان لنفس المستخدم
|
||||
// (الفريد على user_id) — والثاني يعني أن الكود صار موجوداً.
|
||||
const mine = await this.codes.findOne({
|
||||
where: { tenant_id: tenantId, user_id: userId },
|
||||
});
|
||||
if (mine) return mine;
|
||||
}
|
||||
}
|
||||
throw new BadRequestException('could not allocate referral code');
|
||||
}
|
||||
|
||||
/**
|
||||
* يسجّل إحالة عند تسجيل مستخدم جديد بكود دعوة. `pending` — لا تُصرف الآن.
|
||||
*
|
||||
* **لا يرمي عند كود خاطئ**: هذا يُنادى داخل مسار التسجيل، وفشل الإحالة
|
||||
* يجب ألّا يمنع مستخدماً من دخول التطبيق. يعيد `null` ويسجّل السبب.
|
||||
*/
|
||||
async register(
|
||||
tenantId: string,
|
||||
refereeUserId: string,
|
||||
refereeRole: ReferralParty,
|
||||
code: string,
|
||||
currency = 'JOD',
|
||||
): Promise<Referral | null> {
|
||||
const normalized = CouponsService.normalize(code);
|
||||
if (!normalized) return null;
|
||||
|
||||
const owner = await this.codes.findOne({
|
||||
where: { tenant_id: tenantId, code: normalized, status: 'active' },
|
||||
});
|
||||
if (!owner) {
|
||||
this.logger.warn(`unknown referral code ${normalized}`);
|
||||
return null;
|
||||
}
|
||||
if (owner.user_id === refereeUserId) {
|
||||
this.logger.warn(`self-referral blocked for ${refereeUserId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.referrals.save(
|
||||
this.referrals.create({
|
||||
tenant_id: tenantId,
|
||||
referrer_user_id: owner.user_id,
|
||||
referee_user_id: refereeUserId,
|
||||
referrer_role: owner.role,
|
||||
referee_role: refereeRole,
|
||||
code: normalized,
|
||||
status: 'pending',
|
||||
currency,
|
||||
}),
|
||||
);
|
||||
} catch (e: any) {
|
||||
// الفريد على referee: هذا المستخدم مُحال سلفاً — يُحال مرة واحدة فقط.
|
||||
if (e?.code === '23505' || e?.driverError?.code === '23505') {
|
||||
this.logger.warn(`user ${refereeUserId} already referred — ignoring ${normalized}`);
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* يُنادى عند إتمام رحلة: يزيد عدّاد المُحال، ويؤهّله إن بلغ الشرط.
|
||||
*
|
||||
* الترقية `pending → qualified` بـUPDATE شرطي على الحالة، فلا ترقية
|
||||
* مزدوجة لو تزامن نداءان (رحلتان تُنهيان في اللحظة نفسها).
|
||||
*/
|
||||
async onTripCompleted(
|
||||
tenantId: string,
|
||||
riderOrDriverUserId: string,
|
||||
tripId: string,
|
||||
): Promise<void> {
|
||||
const referral = await this.referrals.findOne({
|
||||
where: { tenant_id: tenantId, referee_user_id: riderOrDriverUserId, status: 'pending' },
|
||||
});
|
||||
if (!referral) return;
|
||||
|
||||
const { qualifyingTrips } = await this.policy(tenantId, referral.currency);
|
||||
|
||||
// خطوتان لا عبارة واحدة بـCASE: حساب العتبة **في TypeScript** لا داخل
|
||||
// SQL. المحاولة الأولى وضعت `CASE WHEN qualifying_trips + 1 >= n` فجمع
|
||||
// المحرّك العمود نصّاً ('0' + 1 = '01') ولم تتحقق الشرطية أبداً —
|
||||
// فبقيت كل إحالة معلّقة بلا مكافأة، وهو فشل صامت لا يُكتشف إلا بشكوى.
|
||||
// والتزامن محفوظ: كلتا العبارتين مشروطتان بالحالة، فرحلتان تنتهيان معاً
|
||||
// لا تؤهّلان مرتين.
|
||||
const bumped = await this.referrals
|
||||
.createQueryBuilder()
|
||||
.update(Referral)
|
||||
.set({ qualifying_trips: () => 'qualifying_trips + 1' })
|
||||
.where('id = :id AND tenant_id = :tenant AND status = :pending', {
|
||||
id: referral.id,
|
||||
tenant: tenantId,
|
||||
pending: 'pending',
|
||||
})
|
||||
.returning(['qualifying_trips'])
|
||||
.execute();
|
||||
if (!bumped.affected) return;
|
||||
|
||||
const count = Number(bumped.raw?.[0]?.qualifying_trips ?? NaN);
|
||||
if (!Number.isFinite(count) || count < qualifyingTrips) return;
|
||||
|
||||
const promoted = await this.referrals
|
||||
.createQueryBuilder()
|
||||
.update(Referral)
|
||||
.set({ status: 'qualified', qualified_trip_id: tripId, qualified_at: new Date() })
|
||||
.where('id = :id AND tenant_id = :tenant AND status = :pending', {
|
||||
id: referral.id,
|
||||
tenant: tenantId,
|
||||
pending: 'pending',
|
||||
})
|
||||
.execute();
|
||||
if (!promoted.affected) return; // سبقنا نداء متزامن — هو من سيصرف
|
||||
|
||||
// الصرف الفوري محاولةٌ أولى؛ الماسح شبكة الأمان لو فشلت هنا.
|
||||
const fresh = await this.referrals.findOne({ where: { id: referral.id } });
|
||||
if (fresh?.status === 'qualified') {
|
||||
await this.payout(fresh).catch((e) =>
|
||||
this.logger.error(`immediate payout failed for ${fresh.id}: ${e?.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* يصرف مكافأتَي الطرفين ثم يعلّم الإحالة مدفوعة.
|
||||
*
|
||||
* الترتيب مقصود: **الصرف أولاً ثم التعليم**. لو عُلّمت مدفوعة قبل الصرف
|
||||
* وسقطت العملية بينهما، ضاعت المكافأة بلا أثر ولا إعادة محاولة. والعكس
|
||||
* (صرف ثم سقوط قبل التعليم) يُصلحه الماسح، والحارس الفريد على الدفتر
|
||||
* يمنع الصرف مرتين.
|
||||
*/
|
||||
async payout(referral: Referral): Promise<void> {
|
||||
const policy = await this.policy(referral.tenant_id, referral.currency);
|
||||
|
||||
await this.reward(referral, referral.referrer_user_id, policy, 'referrer');
|
||||
await this.reward(referral, referral.referee_user_id, policy, 'referee');
|
||||
|
||||
await this.referrals
|
||||
.createQueryBuilder()
|
||||
.update(Referral)
|
||||
.set({ status: 'paid', paid_at: new Date() })
|
||||
.where('id = :id AND status = :qualified', { id: referral.id, qualified: 'qualified' })
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** الإحالات المؤهَّلة غير المصروفة — مدخل الماسح. */
|
||||
findQualified(limit = 50): Promise<Referral[]> {
|
||||
return this.referrals.find({
|
||||
where: { status: 'qualified' },
|
||||
order: { qualified_at: 'ASC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/** ملخّص «دعواتي» — للتطبيق. */
|
||||
async summary(tenantId: string, userId: string, role: ReferralParty) {
|
||||
const code = await this.myCode(tenantId, userId, role);
|
||||
const mine = await this.referrals.find({
|
||||
where: { tenant_id: tenantId, referrer_user_id: userId },
|
||||
order: { created_at: 'DESC' },
|
||||
take: 100,
|
||||
});
|
||||
return {
|
||||
code: code.code,
|
||||
total: mine.length,
|
||||
pending: mine.filter((r) => r.status === 'pending').length,
|
||||
paid: mine.filter((r) => r.status === 'paid').length,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- داخلي ----
|
||||
|
||||
/**
|
||||
* يوجّه المكافأة حسب دور المستفيد: سائق ← رصيد · راكب ← كوبون.
|
||||
*
|
||||
* الدور يُحسم **وقت الصرف** من وجود صفّ سائق، لا من `referee_role` المخزَّن
|
||||
* وقت التسجيل: من سجّل بكود دعوة ثم تقدّم سائقاً يستحقّ رصيداً تشغيلياً لا
|
||||
* كوبون خصم. الدور المخزَّن يبقى للتقارير («من أين جاء سائقونا؟»).
|
||||
*/
|
||||
private async reward(
|
||||
referral: Referral,
|
||||
userId: string,
|
||||
policy: RewardsPolicy,
|
||||
side: 'referrer' | 'referee',
|
||||
): Promise<void> {
|
||||
const driver = await this.drivers.findByUser(referral.tenant_id, userId);
|
||||
if (driver) {
|
||||
await this.credit.grantReferralBonus(
|
||||
referral.tenant_id,
|
||||
driver.id,
|
||||
policy.driverReferralBonus,
|
||||
// الطرفان في إحالة واحدة، فالمعرّف وحده يجعل قيديهما متطابقين تحت
|
||||
// الفهرس الفريد فيُرفض ثانيهما — اللاحقة تفصلهما.
|
||||
`${referral.id}:${side}`,
|
||||
referral.currency,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(policy.riderReferralCoupon > 0)) return;
|
||||
await this.coupons.grant({
|
||||
tenantId: referral.tenant_id,
|
||||
userId,
|
||||
amount: policy.riderReferralCoupon,
|
||||
currency: referral.currency,
|
||||
source: 'referral',
|
||||
validDays: policy.referralCouponDays,
|
||||
codePrefix: 'REF',
|
||||
ref: `${referral.id}:${side}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* سياسة المكافآت والكوبونات لكل مستأجر (المجموعة L — قرار المالك 2026-07-18).
|
||||
*
|
||||
* قانونان ثابتان يحكمان الملف كله:
|
||||
*
|
||||
* 1. **محفظة الراكب لا تُسحب أبداً.** فمكافأة الراكب لا تنزل رصيداً في
|
||||
* `wallets` — تنزل **كوبون خصم** يخفّض `price_for_passenger` وحده بينما
|
||||
* `price_for_driver` يبقى كاملاً (docs/18 §52). الفرق تتحمّله المنصة
|
||||
* كتكلفة تسويق، لا يُقتطع من دخل السائق.
|
||||
* 2. **كل خصم مسقوف بعملته.** السقف هنا **حدّ أعلى مطلق** لا افتراض:
|
||||
* كوبون بنسبة 50% على أجرة كبيرة يبقى محكوماً بالسقف. بدونه كوبون واحد
|
||||
* مكسور الإعداد يأكل أجرة بلا نهاية.
|
||||
*/
|
||||
|
||||
/**
|
||||
* أقصى خصم لكوبون واحد لكل عملة — **قرار المالك: 25 جنيهاً في مصر،
|
||||
* ومقابلها في الأردن وسوريا.**
|
||||
*
|
||||
* «المقابل» مشتقّ من الحدود الدنيا التي أقرّها المالك نفسه (1.10 دينار ·
|
||||
* 20 جنيهاً · 150 ليرة)، أي 1 دينار ≈ 18.2 جنيهاً ≈ 136 ليرة. فالسقف 25
|
||||
* جنيهاً يقابل 1.375 ديناراً و187.5 ليرة.
|
||||
*
|
||||
* **النسخة الأولى استعملت 1:100:100** (0.25 دينار · 25 ليرة) نقلاً عن
|
||||
* `credit-policy`، وهي نسبة داخلية قديمة لا تطابق أرقام المالك: كانت تعني
|
||||
* سقفاً في سوريا يساوي **سُدس** رحلة واحدة بالحد الأدنى، وفي الأردن أقلّ من
|
||||
* ربع أرخص رحلة — أي عرض تسويقي لا يُرى أثره.
|
||||
*/
|
||||
export const DEFAULT_MAX_DISCOUNT: Record<string, number> = {
|
||||
JOD: 1.375,
|
||||
SYP: 190, // بالعملة السورية الجديدة (187.5 مدوَّرة لخانة الليرة)
|
||||
EGP: 25,
|
||||
};
|
||||
|
||||
/** مكافأة إحالة السائق — تنزل رصيداً تشغيلياً (`referral_bonus`). */
|
||||
export const DEFAULT_DRIVER_REFERRAL_BONUS: Record<string, number> = {
|
||||
JOD: 1,
|
||||
SYP: 140,
|
||||
EGP: 18,
|
||||
};
|
||||
|
||||
/** مكافأة إحالة الراكب — قيمة الكوبون الممنوح، وتبقى محكومة بالسقف أعلاه. */
|
||||
export const DEFAULT_RIDER_REFERRAL_COUPON: Record<string, number> = {
|
||||
JOD: 1,
|
||||
SYP: 140,
|
||||
EGP: 18,
|
||||
};
|
||||
|
||||
/** صلاحية كوبون الإحالة بالأيام — منتهٍ خير من التزام مفتوح إلى الأبد. */
|
||||
export const DEFAULT_REFERRAL_COUPON_DAYS = 30;
|
||||
|
||||
/**
|
||||
* كم رحلة مكتملة يلزم المُحال قبل استحقاق المكافأة.
|
||||
* 1 لا 0 عمداً: الصرف عند التسجيل يعني أن خمسين رقماً وهمياً = خمسون مكافأة.
|
||||
*/
|
||||
export const DEFAULT_QUALIFYING_TRIPS = 1;
|
||||
|
||||
export interface RewardsPolicy {
|
||||
/** الحدّ الأعلى المطلق لخصم الكوبون الواحد بهذه العملة. */
|
||||
maxDiscount: number;
|
||||
driverReferralBonus: number;
|
||||
riderReferralCoupon: number;
|
||||
referralCouponDays: number;
|
||||
qualifyingTrips: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* يحسب سياسة عملةٍ ما لمستأجر: الافتراض ثم تجاوز `tenants.settings.rewards`.
|
||||
* إعداد مكسور (نص/سالب/NaN) يسقط للافتراض — يجب ألّا يفتح خصماً بلا سقف.
|
||||
*/
|
||||
export function resolveRewardsPolicy(
|
||||
settings: Record<string, any> | null | undefined,
|
||||
currency: string,
|
||||
): RewardsPolicy {
|
||||
const cfg = settings?.rewards ?? {};
|
||||
return {
|
||||
maxDiscount: pick(cfg.max_discount?.[currency], DEFAULT_MAX_DISCOUNT[currency] ?? 0),
|
||||
driverReferralBonus: pick(
|
||||
cfg.driver_referral_bonus?.[currency],
|
||||
DEFAULT_DRIVER_REFERRAL_BONUS[currency] ?? 0,
|
||||
),
|
||||
riderReferralCoupon: pick(
|
||||
cfg.rider_referral_coupon?.[currency],
|
||||
DEFAULT_RIDER_REFERRAL_COUPON[currency] ?? 0,
|
||||
),
|
||||
referralCouponDays: pick(cfg.referral_coupon_days, DEFAULT_REFERRAL_COUPON_DAYS),
|
||||
// الحدّ الأدنى رحلة واحدة: 0 يعني الصرف عند التسجيل وهو باب الاحتيال
|
||||
// الذي بُني هذا الحقل لإغلاقه — لا يُسمح لإعدادٍ بفتحه.
|
||||
qualifyingTrips: Math.max(1, pick(cfg.qualifying_trips, DEFAULT_QUALIFYING_TRIPS)),
|
||||
};
|
||||
}
|
||||
|
||||
/** يقبل 0 صراحةً — `||` كان سيبتلعه ويعيده للافتراض (فخّ الصفر الصادق). */
|
||||
function pick(value: any, fallback: number): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { CouponsService } from './coupons.service';
|
||||
import { ReferralsService } from './referrals.service';
|
||||
import { TenantsService } from '../tenants/tenants.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { ReferralParty } from './entities/referral.entity';
|
||||
|
||||
@ApiTags('rewards')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('rewards')
|
||||
export class RewardsController {
|
||||
constructor(
|
||||
private readonly coupons: CouponsService,
|
||||
private readonly referrals: ReferralsService,
|
||||
private readonly tenants: TenantsService,
|
||||
) {}
|
||||
|
||||
/** كود دعوتي + عدّاد من دعوتُهم — شاشة «ادعُ صديقاً». */
|
||||
@Get('referrals')
|
||||
summary(@CurrentUser() user: AuthUser) {
|
||||
return this.referrals.summary(user.tenantId, user.userId, roleOf(user));
|
||||
}
|
||||
|
||||
/** كوبوناتي الصالحة — شاشة «مكافآتي». */
|
||||
@Get('coupons')
|
||||
mine(@CurrentUser() user: AuthUser) {
|
||||
return this.coupons.listMine(user.tenantId, user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* «كم سيوفّر هذا الكود؟» قبل تأكيد الرحلة — تقييم بلا استهلاك.
|
||||
*
|
||||
* يعيد 200 مع `ok: false` وسبب، لا خطأ 4xx: كود خاطئ حالةٌ متوقّعة في
|
||||
* واجهة يكتب فيها المستخدم بيده، وليست فشلاً في الطلب.
|
||||
*/
|
||||
@Get('coupons/preview')
|
||||
async preview(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('code') code: string,
|
||||
@Query('fare') fare: string,
|
||||
@Query('currency') currency = 'JOD',
|
||||
) {
|
||||
const amount = Number(fare);
|
||||
if (!code || !Number.isFinite(amount) || amount <= 0) {
|
||||
throw new BadRequestException('code and a positive fare are required');
|
||||
}
|
||||
const result = await this.coupons.evaluate(
|
||||
user.tenantId,
|
||||
user.userId,
|
||||
code,
|
||||
amount,
|
||||
currency,
|
||||
);
|
||||
return {
|
||||
ok: result.ok,
|
||||
reason: result.reason,
|
||||
discount: result.discount,
|
||||
capped_by: result.capped_by,
|
||||
fare_after: Number((amount - result.discount).toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* حملة كوبونات جديدة — **أدمن المستأجر**. القيمة المعلنة قد تتجاوز سقف
|
||||
* السياسة هنا بلا خطأ: السقف يُطبَّق وقت الحساب، فالحملة تبقى صالحة
|
||||
* والخصم محكوم. رفضها كان سيمنع «50% حتى 25 جنيهاً» وهو الشكل الطبيعي.
|
||||
*/
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin')
|
||||
@Post('coupons')
|
||||
async create(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body()
|
||||
body: {
|
||||
code: string;
|
||||
discount_type?: 'percent' | 'flat';
|
||||
discount_value: number;
|
||||
max_discount?: number;
|
||||
min_fare?: number;
|
||||
currency?: string;
|
||||
valid_until?: string;
|
||||
max_redemptions?: number;
|
||||
max_per_user?: number;
|
||||
},
|
||||
) {
|
||||
if (!body?.code) throw new BadRequestException('code is required');
|
||||
return this.coupons.createCampaign(user.tenantId, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* سياسة المكافآت — **أدمن المستأجر**، لكل عملة. أهمّها `max_discount`:
|
||||
* الحدّ الأعلى المطلق لخصم أي كوبون (قرار المالك: 25 جنيهاً في مصر
|
||||
* ومقابلها في الأردن وسوريا).
|
||||
*/
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin')
|
||||
@Post('policy')
|
||||
async setPolicy(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body()
|
||||
body: {
|
||||
max_discount?: Record<string, number>;
|
||||
driver_referral_bonus?: Record<string, number>;
|
||||
rider_referral_coupon?: Record<string, number>;
|
||||
referral_coupon_days?: number;
|
||||
qualifying_trips?: number;
|
||||
},
|
||||
) {
|
||||
await this.tenants.updateSettings(user.tenantId, {
|
||||
rewards: {
|
||||
...(body.max_discount ? { max_discount: body.max_discount } : {}),
|
||||
...(body.driver_referral_bonus
|
||||
? { driver_referral_bonus: body.driver_referral_bonus }
|
||||
: {}),
|
||||
...(body.rider_referral_coupon
|
||||
? { rider_referral_coupon: body.rider_referral_coupon }
|
||||
: {}),
|
||||
...(body.referral_coupon_days != null
|
||||
? { referral_coupon_days: body.referral_coupon_days }
|
||||
: {}),
|
||||
...(body.qualifying_trips != null
|
||||
? { qualifying_trips: body.qualifying_trips }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// نعيد **السياسة المحسوبة** لا الخام: الخام تجاوزات فقط، فكان الأدمن
|
||||
// يرى حقولاً فارغة ويظنّ السياسة معطّلة (نفس درس K5).
|
||||
return this.coupons.policy(user.tenantId, 'JOD');
|
||||
}
|
||||
}
|
||||
|
||||
/** السائق راكبٌ أيضاً في نموذج المستخدم — الدور من التوكن هو الفاصل. */
|
||||
function roleOf(user: AuthUser): ReferralParty {
|
||||
return user.role === 'driver' ? 'driver' : 'rider';
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Coupon } from './entities/coupon.entity';
|
||||
import { CouponRedemption } from './entities/coupon-redemption.entity';
|
||||
import { Referral } from './entities/referral.entity';
|
||||
import { ReferralCode } from './entities/referral-code.entity';
|
||||
import { CouponsService } from './coupons.service';
|
||||
import { ReferralsService } from './referrals.service';
|
||||
import { RewardsController } from './rewards.controller';
|
||||
import { RewardsSweeper } from './rewards.sweeper';
|
||||
import { CreditModule } from '../credit/credit.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
import { TenantsModule } from '../tenants/tenants.module';
|
||||
|
||||
/**
|
||||
* الإحالات والكوبونات (المجموعة L).
|
||||
*
|
||||
* `forwardRef` على DriversModule: الرحلات تستورد المكافآت (للخصم والتأهيل)،
|
||||
* والمكافآت تستورد السائقين (لتحويل user_id إلى driver_id) — نفس الدورة
|
||||
* المعروفة في CreditModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Coupon, CouponRedemption, Referral, ReferralCode]),
|
||||
CreditModule,
|
||||
forwardRef(() => DriversModule),
|
||||
TenantsModule,
|
||||
],
|
||||
controllers: [RewardsController],
|
||||
providers: [CouponsService, ReferralsService, RewardsSweeper],
|
||||
exports: [CouponsService, ReferralsService],
|
||||
})
|
||||
export class RewardsModule {}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ReferralsService } from './referrals.service';
|
||||
|
||||
/**
|
||||
* يصرف الإحالات المؤهَّلة التي لم تُصرف (المجموعة L — L5).
|
||||
*
|
||||
* **شبكة أمان لا مسار أساسي**: الصرف يقع لحظة التأهّل داخل
|
||||
* `onTripCompleted`. هذا الماسح يلتقط ما سقط بينهما — انقطاع قاعدة، أو
|
||||
* إعادة نشر في اللحظة الخطأ. بدونه تبقى مكافأة مستحقّة عالقة إلى الأبد
|
||||
* وصاحبها لا يعرف لماذا.
|
||||
*
|
||||
* نفس نمط `ScheduledTripsSweeper`: `setInterval` مع قفل `running` فلا
|
||||
* تتراكب دورة بطيئة مع التالية. آمن مع عدة نسخ لأن `payout` يعلّم بـUPDATE
|
||||
* شرطي (`qualified → paid`)، وفوقه الفهارس الفريدة على دفتري الرصيد
|
||||
* والكوبونات — فنسختان تصرفان معاً لا تمنحان مكافأة مرتين.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RewardsSweeper implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger('RewardsSweeper');
|
||||
private timer?: NodeJS.Timeout;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly referrals: ReferralsService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const intervalMs = this.config.get<number>('rewards.sweepMs') ?? 60_000;
|
||||
this.timer = setInterval(() => void this.tick(), intervalMs);
|
||||
this.logger.log(`rewards sweeper up (every ${intervalMs}ms)`);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const due = await this.referrals.findQualified(50);
|
||||
let paid = 0;
|
||||
for (const referral of due) {
|
||||
try {
|
||||
await this.referrals.payout(referral);
|
||||
paid++;
|
||||
} catch (e: any) {
|
||||
// إحالة واحدة فاسدة يجب ألّا توقف صرف البقية — تُسجَّل وتُعاد
|
||||
// محاولتها الدورة القادمة (ما زالت `qualified`).
|
||||
this.logger.error(`payout failed for ${referral.id}: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
if (paid > 0) this.logger.log(`paid ${paid} referral(s)`);
|
||||
} catch (e: any) {
|
||||
this.logger.error(`sweep failed: ${e?.message}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -54,9 +54,27 @@ export interface TariffDefinition {
|
||||
rounding?: { increment: number; mode?: 'nearest' | 'up' | 'down' };
|
||||
mode: 'time_and_distance' | 'time_or_distance' | 'fixed_quote';
|
||||
speed_threshold_kmh?: number;
|
||||
|
||||
/**
|
||||
* منطقة التعرفة الزمنية (IANA — `Asia/Amman`). **النوافذ تُقرأ بها لا بـUTC.**
|
||||
*
|
||||
* غيابها كان يعني قراءة الساعة بـUTC: نافذة ذروة مكتوبة 16:00–20:00 كانت
|
||||
* تشتغل فعلياً 19:00–23:00 في عمّان، أي تفوت الازدحام وتضرب السهرة.
|
||||
* الافتراض `Asia/Amman` لا UTC — مستأجرونا كلهم UTC+2/+3، وUTC ليست
|
||||
* منطقة أحد منهم.
|
||||
*/
|
||||
timezone?: string;
|
||||
|
||||
windows: TariffWindow[];
|
||||
booking_fee?: number;
|
||||
min_fare?: number;
|
||||
|
||||
/**
|
||||
* الأجرة الثابتة لوضع `fixed_quote` (خط/وجهة بسعر متفَق عليه).
|
||||
* لا مسافة ولا زمن يدخلان الحساب — الانتظار وحده يُضاف، فبقاء الراكب
|
||||
* ربع ساعة عند نقطة اللقاء تكلفة حقيقية على السائق مهما كان الخط ثابتاً.
|
||||
*/
|
||||
fixed_fare?: number;
|
||||
surge?: { enabled: boolean; multiplier?: number; max?: number };
|
||||
|
||||
/** دقائق انتظار مجانية بعد وصول السائق قبل بدء العدّ (docs/17 — B2). القانون: 5. */
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { TariffEngine } from './tariff.engine';
|
||||
import { TariffDefinition } from './entities/tariff.entity';
|
||||
// نستورد مصدر الحقيقة نفسه — الذي يزرعه التطبيق ويولّد منه الـSQL — لا
|
||||
// نسخة موازية منه تتباعد عنه بصمت.
|
||||
import {
|
||||
buildDefinition,
|
||||
COUNTRY_TARIFFS as countries,
|
||||
SERVICE_CLASSES as ServiceClasses,
|
||||
WINDOWS,
|
||||
countryTariff as byPack,
|
||||
} from './default-tariffs';
|
||||
|
||||
const def = (pack: string, cls: string): TariffDefinition =>
|
||||
buildDefinition(byPack(pack), cls);
|
||||
|
||||
/** ظهيرة يوم عادي في كل منطقة — نافذة `normal_day`. */
|
||||
const NOON_AMMAN = new Date('2026-07-19T09:00:00Z'); // 12:00 بتوقيت عمّان (+3)
|
||||
|
||||
describe('تعرفة الانطلاق — الحدود الدنيا بقرار المالك', () => {
|
||||
it('الحدّ الأدنى: 1.10 دينار · 20 جنيهاً · 150 ليرة', () => {
|
||||
expect(def('jo', ServiceClasses.ECONOMY).min_fare).toBe(1.1);
|
||||
expect(def('eg', ServiceClasses.ECONOMY).min_fare).toBe(20);
|
||||
expect(def('sy', ServiceClasses.ECONOMY).min_fare).toBe(150);
|
||||
});
|
||||
|
||||
it('رحلة قصيرة جداً تُرفع للحدّ الأدنى لا تُحاسَب بالعدّاد', () => {
|
||||
for (const [pack, floor] of [['jo', 1.1], ['eg', 20], ['sy', 150]] as const) {
|
||||
const q = TariffEngine.quote(def(pack, ServiceClasses.ECONOMY), {
|
||||
distanceKm: 0.3,
|
||||
durationMin: 2,
|
||||
at: NOON_AMMAN,
|
||||
});
|
||||
expect(q.total).toBeGreaterThanOrEqual(floor);
|
||||
}
|
||||
});
|
||||
|
||||
it('فتحة العدّاد موجودة في كل نافذة ولكل بلد — لا صفر', () => {
|
||||
for (const country of countries) {
|
||||
for (const w of def(country.countryPack, ServiceClasses.ECONOMY).windows) {
|
||||
// الفتحة صفراً كانت تجعل الرحلة القصيرة خاسرة على السائق.
|
||||
expect(w.flag).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('تعرفة الانطلاق — ترتيب الفئات', () => {
|
||||
// الخلل الذي كسر النسخة السابقة: الفان أرخص من الاقتصادي في مصر وسوريا،
|
||||
// فيصير الخيار العقلاني لكل راكب هو أغلى فئة على المشغّل.
|
||||
const ordered = [
|
||||
ServiceClasses.SAVER,
|
||||
ServiceClasses.ECONOMY,
|
||||
ServiceClasses.ELECTRIC,
|
||||
ServiceClasses.LADY,
|
||||
ServiceClasses.COMFORT,
|
||||
ServiceClasses.VAN,
|
||||
ServiceClasses.VIP,
|
||||
];
|
||||
|
||||
for (const country of countries) {
|
||||
it(`${country.countryPack}: السعر يتصاعد مع الفئة بلا انعكاس`, () => {
|
||||
const fares = ordered.map(
|
||||
(cls) =>
|
||||
TariffEngine.quote(def(country.countryPack, cls), {
|
||||
distanceKm: 5,
|
||||
durationMin: 15,
|
||||
at: NOON_AMMAN,
|
||||
}).total,
|
||||
);
|
||||
for (let i = 1; i < fares.length; i++) {
|
||||
expect(fares[i]).toBeGreaterThan(fares[i - 1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('النوافذ الزمنية — بالتوقيت المحلي لا UTC', () => {
|
||||
it('تغطي اليوم كاملاً بلا ثغرة ولا تداخل', () => {
|
||||
const toMin = (s: string) => Number(s.slice(0, 2)) * 60 + Number(s.slice(3));
|
||||
for (let i = 0; i < WINDOWS.length; i++) {
|
||||
const next = WINDOWS[(i + 1) % WINDOWS.length];
|
||||
// نهاية كل نافذة = بداية التالية بالضبط؛ أي فجوة تعني دقائق بلا سعر.
|
||||
expect(toMin(WINDOWS[i].to)).toBe(toMin(next.from));
|
||||
}
|
||||
});
|
||||
|
||||
it('**الذروة المسائية تقع مساءً في عمّان لا بعد منتصف الليل**', () => {
|
||||
const d = def('jo', ServiceClasses.ECONOMY);
|
||||
// 17:00 بتوقيت عمّان = 14:00 UTC. بالحساب القديم (UTC) كانت الساعة
|
||||
// تُقرأ 14:00 فتقع في `normal_day` — أي ذروة لا تُحصَّل أبداً.
|
||||
const q = TariffEngine.quote(d, {
|
||||
distanceKm: 5,
|
||||
durationMin: 15,
|
||||
at: new Date('2026-07-19T14:00:00Z'),
|
||||
});
|
||||
expect(q.window).toBe('evening_peak');
|
||||
});
|
||||
|
||||
it('الظهيرة نافذة عادية، والفجر نافذة ليلية — في كل بلد بمنطقته', () => {
|
||||
expect(
|
||||
TariffEngine.quote(def('jo', ServiceClasses.ECONOMY), {
|
||||
distanceKm: 1,
|
||||
durationMin: 1,
|
||||
at: new Date('2026-07-19T09:00:00Z'), // 12:00 عمّان
|
||||
}).window,
|
||||
).toBe('normal_day');
|
||||
|
||||
expect(
|
||||
TariffEngine.quote(def('eg', ServiceClasses.ECONOMY), {
|
||||
distanceKm: 1,
|
||||
durationMin: 1,
|
||||
at: new Date('2026-07-19T00:30:00Z'), // 02:30 القاهرة
|
||||
}).window,
|
||||
).toBe('late_night');
|
||||
});
|
||||
|
||||
it('الذروة أغلى من النافذة العادية لنفس الرحلة', () => {
|
||||
const d = def('jo', ServiceClasses.ECONOMY);
|
||||
const normal = TariffEngine.quote(d, {
|
||||
distanceKm: 8,
|
||||
durationMin: 20,
|
||||
at: new Date('2026-07-19T09:00:00Z'),
|
||||
}).total;
|
||||
const peak = TariffEngine.quote(d, {
|
||||
distanceKm: 8,
|
||||
durationMin: 20,
|
||||
at: new Date('2026-07-19T14:00:00Z'),
|
||||
}).total;
|
||||
expect(peak).toBeGreaterThan(normal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('السعر الثابت (fixed_quote)', () => {
|
||||
it('**لا يتغير بالمسافة ولا بالزمن** — هذا معنى «ثابت»', () => {
|
||||
const d = def('eg', ServiceClasses.FIXED);
|
||||
const short = TariffEngine.quote(d, { distanceKm: 2, durationMin: 5, at: NOON_AMMAN });
|
||||
const long = TariffEngine.quote(d, { distanceKm: 40, durationMin: 90, at: NOON_AMMAN });
|
||||
// سابقاً كان `fixed_quote` يسقط للفرع المتري فيُحسب بالعدّاد رغم اسمه.
|
||||
expect(short.total).toBe(long.total);
|
||||
expect(short.total).toBe(byPack('eg').fixedFare);
|
||||
});
|
||||
|
||||
it('الانتظار وحده يُضاف — وقت السائق تكلفة حقيقية مهما ثبت الخط', () => {
|
||||
const d = def('eg', ServiceClasses.FIXED);
|
||||
const withWait = TariffEngine.quote(d, {
|
||||
distanceKm: 10,
|
||||
durationMin: 20,
|
||||
waitingMin: 10,
|
||||
at: NOON_AMMAN,
|
||||
});
|
||||
expect(withWait.total).toBeGreaterThan(byPack('eg').fixedFare);
|
||||
expect(withWait.distance).toBe(0);
|
||||
expect(withWait.time).toBe(0);
|
||||
});
|
||||
|
||||
it('لا حدّ أدنى يرفع السعر المعلن — المعلَن هو المتفَق عليه', () => {
|
||||
const d = def('sy', ServiceClasses.FIXED);
|
||||
expect(d.min_fare).toBe(0);
|
||||
expect(
|
||||
TariffEngine.quote(d, { distanceKm: 0.5, durationMin: 1, at: NOON_AMMAN }).total,
|
||||
).toBe(byPack('sy').fixedFare);
|
||||
});
|
||||
});
|
||||
|
||||
describe('العمولة', () => {
|
||||
it('تُحسب بنسبة البلد على أجرة الراكب', () => {
|
||||
const d = def('jo', ServiceClasses.ECONOMY);
|
||||
const q = TariffEngine.quote(d, { distanceKm: 10, durationMin: 25, at: NOON_AMMAN });
|
||||
const c = TariffEngine.commission(d, q.total);
|
||||
expect(c.rate).toBe(14);
|
||||
expect(c.amount).toBeCloseTo(q.total * 0.14, 2);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export interface QuoteBreakdown {
|
||||
export class TariffEngine {
|
||||
static quote(def: TariffDefinition, input: QuoteInput): QuoteBreakdown {
|
||||
const at = input.at ?? new Date();
|
||||
const win = TariffEngine.pickWindow(def.windows, at);
|
||||
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);
|
||||
@@ -36,6 +36,36 @@ export class TariffEngine {
|
||||
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);
|
||||
// الانتظار وحده يُضاف: بقاء الراكب ربع ساعة تكلفة حقيقية على السائق
|
||||
// مهما كان الخط ثابتاً. ولا حدّ أدنى هنا — السعر المعلن **هو** الاتفاق،
|
||||
// ورفعه بحدّ أدنى يعني إعلان سعر وتحصيل غيره.
|
||||
const multiplier =
|
||||
(def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1) *
|
||||
(input.geofenceMultiplier ?? 1.0);
|
||||
const fixedTotal = TariffEngine.round(
|
||||
(fixed + waitingOnly) * multiplier,
|
||||
def.rounding,
|
||||
);
|
||||
return {
|
||||
window: win.name,
|
||||
flag: 0,
|
||||
distance: 0,
|
||||
time: 0,
|
||||
waiting: TariffEngine.n(waitingOnly),
|
||||
bookingFee: 0,
|
||||
subtotal: TariffEngine.n(fixed + waitingOnly),
|
||||
surgeMultiplier: multiplier,
|
||||
total: fixedTotal,
|
||||
currency: def.currency,
|
||||
};
|
||||
}
|
||||
|
||||
if (def.mode === 'time_or_distance') {
|
||||
// العداد المنظَّم: تحت العتبة يُحسب بالدقيقة (زحمة)، فوقها بالكيلومتر.
|
||||
const avgSpeed = durationMin > 0 ? (distanceKm / durationMin) * 60 : 999;
|
||||
@@ -46,7 +76,7 @@ export class TariffEngine {
|
||||
distanceCharge = distanceKm * win.per_km;
|
||||
}
|
||||
} else {
|
||||
// time_and_distance (و fixed_quote يُحسب مرة ويثبّت)
|
||||
// time_and_distance — العدّاد المعتاد: فتحة + مسافة + زمن.
|
||||
distanceCharge = distanceKm * win.per_km;
|
||||
timeCharge = durationMin * win.per_min;
|
||||
}
|
||||
@@ -91,7 +121,7 @@ export class TariffEngine {
|
||||
waitedMin: number,
|
||||
at: Date = new Date(),
|
||||
): { freeMin: number; billableMin: number; charge: number } {
|
||||
const win = TariffEngine.pickWindow(def.windows, at);
|
||||
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 {
|
||||
@@ -114,7 +144,7 @@ export class TariffEngine {
|
||||
durationMin: number,
|
||||
at: Date = new Date(),
|
||||
): number {
|
||||
const win = TariffEngine.pickWindow(def.windows, at);
|
||||
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);
|
||||
@@ -144,8 +174,39 @@ export class TariffEngine {
|
||||
return { amount: TariffEngine.n(amount), rate: percent };
|
||||
}
|
||||
|
||||
private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow {
|
||||
const mins = at.getUTCHours() * 60 + at.getUTCMinutes();
|
||||
/**
|
||||
* دقائق اليوم بالتوقيت **المحلي للتعرفة** لا بـ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);
|
||||
|
||||
@@ -94,6 +94,39 @@ export class Trip {
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
price_for_driver: number | null;
|
||||
|
||||
// ---- المسافة المقاسة مقابل المقدَّرة (docs/17 — B12) ----
|
||||
|
||||
/** مجموع مقاطع GPS المقبولة أثناء الرحلة — ما قُطع فعلاً. */
|
||||
@Column({ type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
actual_distance_km: number | null;
|
||||
|
||||
/** ما حُوسب عليه فعلاً (الفعلي بعد قصّ السقف). */
|
||||
@Column({ type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
billed_distance_km: number | null;
|
||||
|
||||
/**
|
||||
* `actual / routed`. يُخزَّن ليُقرأ الانحراف لاحقاً بلا إعادة حساب:
|
||||
* سائق نسبته 1.6 في كل رحلاته نمطٌ يستحق النظر، لا حادثة مرور.
|
||||
*/
|
||||
@Column({ type: 'numeric', precision: 6, scale: 3, nullable: true })
|
||||
distance_ratio: number | null;
|
||||
|
||||
/**
|
||||
* كود الكوبون الذي اختاره الراكب عند الطلب (المجموعة L). يُخزَّن نصاً لا
|
||||
* مفتاحاً أجنبياً: الرحلة سجلّ تاريخي، فحذف حملة قديمة يجب ألّا يمحو
|
||||
* سبب الخصم من رحلة مضت.
|
||||
*/
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
coupon_code: string | null;
|
||||
|
||||
/**
|
||||
* الخصم المطبَّق فعلاً (بعد السقف). يُقتطع من `price_for_passenger` وحده —
|
||||
* `price_for_driver` يبقى كاملاً، فالكوبون تكلفة تسويق على المنصة لا على
|
||||
* السائق (docs/18 §52).
|
||||
*/
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
coupon_discount: number | null;
|
||||
|
||||
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
commission_amount: number | null;
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { TripsService } from './trips.service';
|
||||
import { buildDefinition, countryTariff, SERVICE_CLASSES } from '../tariff/default-tariffs';
|
||||
|
||||
const TENANT = 't-1';
|
||||
const TRIP = 'trip-1';
|
||||
const RIDER = 'rider-1';
|
||||
const DRIVER = 'driver-1';
|
||||
|
||||
const DEF = buildDefinition(countryTariff('jo'), SERVICE_CLASSES.ECONOMY);
|
||||
const FIXED_DEF = buildDefinition(countryTariff('jo'), SERVICE_CLASSES.FIXED);
|
||||
|
||||
/**
|
||||
* يبني `TripsService` بأقلّ ما يحتاجه `settleFare` — الباقي `undefined`
|
||||
* عمداً: أي تبعية يلمسها هذا المسار ولم تُمرَّر ستنفجر بوضوح بدل أن تمرّ
|
||||
* صامتة، وهذا هو المطلوب من اختبار مسار مالي.
|
||||
*/
|
||||
function build(opts: {
|
||||
row: any;
|
||||
def?: any;
|
||||
billable?: any;
|
||||
couponDiscount?: number;
|
||||
}) {
|
||||
const trips = { findOne: jest.fn().mockResolvedValue(opts.row) } as any;
|
||||
const coupons = {
|
||||
redeem: jest.fn().mockResolvedValue(opts.couponDiscount ?? 0),
|
||||
} as any;
|
||||
const distances = {
|
||||
billableFor: jest.fn().mockResolvedValue(
|
||||
opts.billable ?? {
|
||||
billableKm: opts.row.distance_km,
|
||||
actualKm: opts.row.distance_km,
|
||||
routedKm: opts.row.distance_km,
|
||||
ratio: 1,
|
||||
capped: false,
|
||||
points: 50,
|
||||
source: 'gps',
|
||||
},
|
||||
),
|
||||
} as any;
|
||||
|
||||
const svc = new TripsService(
|
||||
trips,
|
||||
{} as any, // events
|
||||
{} as any, // maps
|
||||
{} as any, // tariff
|
||||
{} as any, // matching
|
||||
{} as any, // drivers
|
||||
{} as any, // gateway
|
||||
{} as any, // fraud
|
||||
{} as any, // wallet
|
||||
{} as any, // notifications
|
||||
{} as any, // state
|
||||
{} as any, // users
|
||||
{} as any, // locations
|
||||
{} as any, // credit
|
||||
{} as any, // geofence
|
||||
coupons,
|
||||
{} as any, // referrals
|
||||
distances,
|
||||
);
|
||||
// `tariffDef` يقرأ التعرفة الفعّالة — نثبّتها لأن المقصود هنا حساب المال.
|
||||
(svc as any).tariffDef = jest.fn().mockResolvedValue(opts.def ?? DEF);
|
||||
return { svc, coupons, distances };
|
||||
}
|
||||
|
||||
const settle = (svc: TripsService, snapshot: any) =>
|
||||
(svc as any).settleFare(TENANT, TRIP, snapshot) as Promise<any>;
|
||||
|
||||
const baseRow = {
|
||||
id: TRIP,
|
||||
rider_id: RIDER,
|
||||
driver_id: DRIVER,
|
||||
currency: 'JOD',
|
||||
waiting_charge: 0,
|
||||
is_destination_match: false,
|
||||
coupon_code: null,
|
||||
distance_km: 10,
|
||||
duration_min: 20,
|
||||
started_at: new Date('2026-07-19T09:00:00Z'), // 12:00 عمّان — نافذة عادية
|
||||
completed_at: new Date('2026-07-19T09:20:00Z'),
|
||||
};
|
||||
|
||||
const snapshot = { quoted_fare: 4.0 } as any;
|
||||
|
||||
describe('settleFare — تسعير النهاية', () => {
|
||||
it('يعيد التسعير على المسافة المقطوعة فعلاً لا على تقدير الطلب', async () => {
|
||||
// قُدّرت 10 كم وقُطعت 12 (تحويلة ضمن السماحية) → الأجرة ترتفع.
|
||||
const { svc } = build({
|
||||
row: baseRow,
|
||||
billable: {
|
||||
billableKm: 12,
|
||||
actualKm: 12,
|
||||
routedKm: 10,
|
||||
ratio: 1.2,
|
||||
capped: false,
|
||||
points: 60,
|
||||
source: 'gps',
|
||||
},
|
||||
});
|
||||
const out = await settle(svc, snapshot);
|
||||
|
||||
expect(out.billed_distance_km).toBe(12);
|
||||
expect(out.actual_distance_km).toBe(12);
|
||||
expect(out.distance_ratio).toBe(1.2);
|
||||
// 0.39 فتحة + 12×0.28 مسافة + 20×0.06 زمن + 0.25 رسم حجز = 5.20
|
||||
expect(out.final_fare).toBeCloseTo(5.2, 2);
|
||||
// السعر المقفول وقت الطلب (4.0) لم يعد هو الحاكم.
|
||||
expect(out.final_fare).not.toBe(4.0);
|
||||
});
|
||||
|
||||
it('نبضات قليلة → يبقى السعر المقفول، والقياس يُسجَّل للتدقيق', async () => {
|
||||
const { svc } = build({
|
||||
row: baseRow,
|
||||
billable: {
|
||||
billableKm: 10,
|
||||
actualKm: 0.3,
|
||||
routedKm: 10,
|
||||
ratio: 0.03,
|
||||
capped: false,
|
||||
points: 2,
|
||||
source: 'route',
|
||||
},
|
||||
});
|
||||
const out = await settle(svc, snapshot);
|
||||
expect(out.final_fare).toBe(4.0); // السعر المقفول
|
||||
expect(out.actual_distance_km).toBe(0.3); // مسجَّل رغم أنه لم يُحاسَب عليه
|
||||
expect(out.billed_distance_km).toBeNull();
|
||||
});
|
||||
|
||||
it('**السعر الثابت لا يُعاد تسعيره بالمسافة**', async () => {
|
||||
const { svc, distances } = build({
|
||||
row: { ...baseRow, distance_km: 40 },
|
||||
def: FIXED_DEF,
|
||||
});
|
||||
const out = await settle(svc, snapshot);
|
||||
expect(out.final_fare).toBe(4.0);
|
||||
// ولا يُقرأ الـGPS أصلاً — لا معنى لقياس لا يؤثر.
|
||||
expect(distances.billableFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('الانتظار يُضاف فوق الأجرة المعاد تسعيرها', async () => {
|
||||
const { svc } = build({ row: { ...baseRow, waiting_charge: 1.2 } });
|
||||
const out = await settle(svc, snapshot);
|
||||
// 0.39 + 10×0.28 + 20×0.06 = 4.39 → +1.2 انتظار = 5.59 → تدوير
|
||||
expect(out.final_fare).toBeGreaterThan(5.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settleFare — الكوبون وتقسيم المال', () => {
|
||||
it('**الكوبون يخصم من الراكب والسائق يقبض كاملاً**', async () => {
|
||||
const { svc } = build({
|
||||
row: { ...baseRow, coupon_code: 'SAVE1' },
|
||||
couponDiscount: 1.0,
|
||||
});
|
||||
const out = await settle(svc, snapshot);
|
||||
|
||||
expect(out.coupon_discount).toBe(1.0);
|
||||
expect(out.price_for_passenger).toBeCloseTo(out.final_fare - 1.0, 3);
|
||||
// القاعدة التي لا تُكسر: الخصم تكلفة تسويق على المنصة لا على السائق.
|
||||
expect(out.price_for_driver).toBe(out.final_fare);
|
||||
expect(out.price_for_driver).toBeGreaterThan(out.price_for_passenger);
|
||||
});
|
||||
|
||||
it('**العمولة تُحتسب قبل الكوبون** — المنصة لا تدفع الخصم مرتين', async () => {
|
||||
const withCoupon = await settle(
|
||||
build({ row: { ...baseRow, coupon_code: 'SAVE1' }, couponDiscount: 1.0 }).svc,
|
||||
snapshot,
|
||||
);
|
||||
const without = await settle(build({ row: baseRow }).svc, snapshot);
|
||||
// نفس الرحلة، نفس العمولة — الكوبون لا يقضم عمولة المنصة.
|
||||
expect(withCoupon.commission_amount).toBe(without.commission_amount);
|
||||
expect(withCoupon.commission_amount).toBeCloseTo(withCoupon.final_fare * 0.14, 2);
|
||||
});
|
||||
|
||||
it('بلا كوبون: الراكب والسائق على نفس الرقم', async () => {
|
||||
const out = await settle(build({ row: baseRow }).svc, snapshot);
|
||||
expect(out.coupon_discount).toBeNull();
|
||||
expect(out.price_for_passenger).toBe(out.price_for_driver);
|
||||
});
|
||||
|
||||
it('كوبون سقط بين الطلب والتسوية = خصم صفر لا رحلة معلّقة', async () => {
|
||||
// `redeem` تُرجع 0 حين ينفد الكود أو ينتهي — التسوية تكمل بلا خصم.
|
||||
const { svc } = build({
|
||||
row: { ...baseRow, coupon_code: 'DEAD' },
|
||||
couponDiscount: 0,
|
||||
});
|
||||
const out = await settle(svc, snapshot);
|
||||
expect(out.coupon_discount).toBeNull();
|
||||
expect(out.price_for_passenger).toBe(out.price_for_driver);
|
||||
});
|
||||
|
||||
it('خصم مطابقة الوجهة والكوبون يجتمعان بلا أجرة سالبة', async () => {
|
||||
const { svc } = build({
|
||||
row: { ...baseRow, is_destination_match: true, coupon_code: 'SAVE1' },
|
||||
def: { ...DEF, destination_match_discount_pct: 20 },
|
||||
couponDiscount: 1.0,
|
||||
});
|
||||
const out = await settle(svc, snapshot);
|
||||
expect(out.destination_match_discount).toBeGreaterThan(0);
|
||||
expect(out.price_for_passenger).toBeGreaterThan(0);
|
||||
expect(out.price_for_passenger).toBeLessThan(out.price_for_driver);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { TripDistanceService } from './trip-distance.service';
|
||||
|
||||
/** نقطة على مسار مستقيم — ~111 متراً لكل 0.001 درجة عرض. */
|
||||
const pt = (lat: number, lng: number, secondsFromStart: number) => ({
|
||||
lat,
|
||||
lng,
|
||||
recorded_at: new Date(Date.UTC(2026, 6, 19, 10, 0, secondsFromStart)),
|
||||
});
|
||||
|
||||
/** مسار مستقيم شمالاً بخطوات منتظمة. */
|
||||
function straightTrack(steps: number, stepDeg = 0.001, everySec = 10) {
|
||||
return Array.from({ length: steps }, (_, i) =>
|
||||
pt(31.95 + i * stepDeg, 35.93, i * everySec),
|
||||
);
|
||||
}
|
||||
|
||||
describe('sumSegments — تنقية إشارات GPS', () => {
|
||||
it('يجمع مساراً مستقيماً بدقة معقولة', () => {
|
||||
// 10 خطوات × 0.001° ≈ 1.11 كم
|
||||
const km = TripDistanceService.sumSegments(straightTrack(11));
|
||||
expect(km).toBeGreaterThan(1.0);
|
||||
expect(km).toBeLessThan(1.2);
|
||||
});
|
||||
|
||||
it('**يتجاهل ذبذبة الوقوف** — لا يخترع كيلومترات من جهاز واقف', () => {
|
||||
// جهاز واقف عند إشارة: تذبذب أمتار قليلة، مئة نبضة.
|
||||
const jitter = Array.from({ length: 100 }, (_, i) =>
|
||||
pt(31.95 + (i % 2) * 0.00001, 35.93, i * 5),
|
||||
);
|
||||
// بلا حدّ أدنى للمقطع كان مجموع الذبذبات يصير أجرة يدفعها الراكب.
|
||||
expect(TripDistanceService.sumSegments(jitter)).toBe(0);
|
||||
});
|
||||
|
||||
it('**يتجاهل قفزة GPS** — نفق أو فقدان إشارة ليس مسافة مقطوعة', () => {
|
||||
const points = [
|
||||
pt(31.95, 35.93, 0),
|
||||
pt(31.951, 35.93, 10),
|
||||
pt(32.5, 35.93, 20), // قفزة ~61 كم في 10 ثوانٍ
|
||||
pt(32.501, 35.93, 30),
|
||||
];
|
||||
const km = TripDistanceService.sumSegments(points);
|
||||
// المقطعان الشرعيان فقط (~0.11 + ~0.11)، والقفزة مستبعَدة.
|
||||
expect(km).toBeLessThan(0.3);
|
||||
});
|
||||
|
||||
it('يتجاهل المقطع بعد فجوة زمنية طويلة (انقطاع اتصال)', () => {
|
||||
const points = [
|
||||
pt(31.95, 35.93, 0),
|
||||
pt(31.951, 35.93, 10),
|
||||
pt(31.96, 35.93, 10 + 600), // بعد 10 دقائق صمت
|
||||
];
|
||||
const km = TripDistanceService.sumSegments(points);
|
||||
expect(km).toBeLessThan(0.15); // المقطع الأول وحده
|
||||
});
|
||||
|
||||
it('نقطة واحدة أو لا شيء = صفر لا خطأ', () => {
|
||||
expect(TripDistanceService.sumSegments([])).toBe(0);
|
||||
expect(TripDistanceService.sumSegments([pt(31.95, 35.93, 0)])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decide — المسافة المحاسَبة', () => {
|
||||
it('المطابق للمسار يُحاسَب كما هو', () => {
|
||||
const d = TripDistanceService.decide({ actualKm: 10, routedKm: 10, points: 50 });
|
||||
expect(d.billableKm).toBe(10);
|
||||
expect(d.capped).toBe(false);
|
||||
expect(d.source).toBe('gps');
|
||||
});
|
||||
|
||||
it('**التحويلة المعقولة يقبضها السائق** — أطول من التقدير ضمن السماحية', () => {
|
||||
// إغلاق شارع: قطع 11.5 كم بدل 10 المقدَّرة. حرمانه منها يعني أن يخسر
|
||||
// مسافةً قطعها فعلاً لأن التقدير أخطأ.
|
||||
const d = TripDistanceService.decide({ actualKm: 11.5, routedKm: 10, points: 60 });
|
||||
expect(d.billableKm).toBe(11.5);
|
||||
expect(d.capped).toBe(false);
|
||||
});
|
||||
|
||||
it('**الجولة المخترَعة تُقصّ بالسقف** — الطول لا يصير ربحاً بلا حدّ', () => {
|
||||
const d = TripDistanceService.decide({ actualKm: 25, routedKm: 10, points: 80 });
|
||||
expect(d.billableKm).toBe(12.5); // 10 × 1.25
|
||||
expect(d.capped).toBe(true);
|
||||
expect(d.ratio).toBe(2.5); // يبقى مسجَّلاً للمراجعة
|
||||
});
|
||||
|
||||
it('**الطريق الأقصر يُحاسَب بالأقصر** — الراكب لا يدفع تقديراً لم يُقطع', () => {
|
||||
const d = TripDistanceService.decide({ actualKm: 7, routedKm: 10, points: 40 });
|
||||
expect(d.billableKm).toBe(7);
|
||||
});
|
||||
|
||||
it('نبضات قليلة = رجوع للمسار المقدَّر لا لرقم مخترَع من الضجيج', () => {
|
||||
const d = TripDistanceService.decide({ actualKm: 0.4, routedKm: 10, points: 3 });
|
||||
expect(d.billableKm).toBe(10);
|
||||
expect(d.source).toBe('route');
|
||||
// القياس يبقى مسجَّلاً حتى حين لا يُحاسَب عليه.
|
||||
expect(d.actualKm).toBe(0.4);
|
||||
});
|
||||
|
||||
it('بلا نبضات إطلاقاً (GPS مطفأ) = المسار المقدَّر', () => {
|
||||
const d = TripDistanceService.decide({ actualKm: 0, routedKm: 8, points: 0 });
|
||||
expect(d.billableKm).toBe(8);
|
||||
expect(d.source).toBe('route');
|
||||
});
|
||||
|
||||
it('السماحية قابلة للضبط لكل مستأجر', () => {
|
||||
const strict = TripDistanceService.decide({
|
||||
actualKm: 12,
|
||||
routedKm: 10,
|
||||
points: 50,
|
||||
tolerance: 1.05,
|
||||
});
|
||||
expect(strict.billableKm).toBe(10.5);
|
||||
expect(strict.capped).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Between, Repository } from 'typeorm';
|
||||
import { DriverTrack } from '../locations/entities/driver-track.entity';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
|
||||
/**
|
||||
* أقصر مسافة بين نبضتين تُحتسب. أقل منها ضجيج GPS لا حركة: جهاز واقف عند
|
||||
* إشارة مرور يتذبذب أمتاراً، وجمع الذبذبات على رحلة طويلة يخترع كيلومترات
|
||||
* لم تُقطَع — والراكب يدفعها.
|
||||
*/
|
||||
const MIN_SEGMENT_KM = 0.01;
|
||||
|
||||
/**
|
||||
* أقصى مسافة بين نبضتين متتاليتين تُقبل. أطول منها قفزة GPS (نفق، فقدان
|
||||
* إشارة، إعادة اتصال) لا مسافة مقطوعة — تُتجاهل بدل أن تُضاف للأجرة.
|
||||
*/
|
||||
const MAX_SEGMENT_KM = 2;
|
||||
|
||||
/** أقصى فجوة زمنية بين نبضتين قبل اعتبار المقطع منقطعاً. */
|
||||
const MAX_SEGMENT_GAP_MS = 120_000;
|
||||
|
||||
export interface BillableDistance {
|
||||
/** ما ستُحسب عليه الأجرة فعلاً. */
|
||||
billableKm: number;
|
||||
/** مجموع مقاطع GPS المقبولة. */
|
||||
actualKm: number;
|
||||
/** ما قدّره المزوّد وقت الطلب. */
|
||||
routedKm: number;
|
||||
/** `actual / routed` — 1.0 يعني مطابقة تامة. */
|
||||
ratio: number;
|
||||
/** هل قُصّت المسافة بالسقف؟ (مؤشر تحقيق لا اتهام). */
|
||||
capped: boolean;
|
||||
/** عدد نبضات GPS المستعملة — قليلها يعني ثقة أقل. */
|
||||
points: number;
|
||||
source: 'gps' | 'route';
|
||||
}
|
||||
|
||||
/**
|
||||
* المسافة المحتسَبة للرحلة: إشارات GPS مقابل المسار المقدَّر (نظير
|
||||
* `car_tracks` عند سيرو).
|
||||
*
|
||||
* **لماذا لا نكتفي بمسافة المسار؟** لأنها تقدير وقت الطلب: إغلاق شارع أو
|
||||
* تحويلة تجعل السائق يقطع أكثر مما قُدّر، فيخسر فرق مسافة قطعها فعلاً.
|
||||
*
|
||||
* **ولماذا لا نكتفي بالـGPS؟** لأن الرحلة الأطول تعني أجرة أعلى، فالتجوال
|
||||
* بلا داعٍ يصير ربحاً. القاعدة هنا: **يُحاسَب الأطول من الاثنين، بسقف
|
||||
* `routed × tolerance`.** فالسائق يقبض تحويلته الحقيقية، ولا يقبض جولةً
|
||||
* اخترعها. وما تجاوز السقف يُعلَّم للمراجعة لا يُخصم صامتاً.
|
||||
*
|
||||
* والانحراف تحت المسار (طريق أقصر) يُحاسَب بالأقصر — الراكب لا يدفع تقديراً
|
||||
* أعلى مما قُطع.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TripDistanceService {
|
||||
private readonly logger = new Logger('TripDistance');
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DriverTrack)
|
||||
private readonly tracks: Repository<DriverTrack>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* يجمع مقاطع GPS بين لحظتين لسائق. **دالة صافية على المدخلات** بعد القراءة،
|
||||
* فمنطق التصفية قابل للاختبار وحده عبر `sumSegments`.
|
||||
*/
|
||||
async actualDistanceKm(
|
||||
tenantId: string,
|
||||
driverId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<{ km: number; points: number }> {
|
||||
const points = await this.tracks.find({
|
||||
where: {
|
||||
tenant_id: tenantId,
|
||||
driver_id: driverId,
|
||||
recorded_at: Between(from, to),
|
||||
},
|
||||
order: { recorded_at: 'ASC' },
|
||||
});
|
||||
return { km: TripDistanceService.sumSegments(points), points: points.length };
|
||||
}
|
||||
|
||||
/** مجموع المقاطع المقبولة — الضجيج والقفزات تُستبعد (دالة صافية). */
|
||||
static sumSegments(
|
||||
points: Array<Pick<DriverTrack, 'lat' | 'lng' | 'recorded_at'>>,
|
||||
): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1];
|
||||
const b = points[i];
|
||||
const gap = new Date(b.recorded_at).getTime() - new Date(a.recorded_at).getTime();
|
||||
if (!(gap >= 0) || gap > MAX_SEGMENT_GAP_MS) continue;
|
||||
|
||||
const seg = MapsService.haversineKm(
|
||||
{ lat: Number(a.lat), lng: Number(a.lng) },
|
||||
{ lat: Number(b.lat), lng: Number(b.lng) },
|
||||
);
|
||||
if (!Number.isFinite(seg) || seg < MIN_SEGMENT_KM || seg > MAX_SEGMENT_KM) continue;
|
||||
total += seg;
|
||||
}
|
||||
return Number(total.toFixed(3));
|
||||
}
|
||||
|
||||
/**
|
||||
* يقرّر المسافة المحاسَبة — **دالة صافية** لتُختبر بلا قاعدة.
|
||||
*
|
||||
* `minPoints` حارس ثقة: رحلة بأربع نبضات لا تكفي لبناء مسار، والاعتماد
|
||||
* عليها كان سيحاسب راكباً على مسافة مخترَعة من ضجيج. عندها نرجع للمسار
|
||||
* المقدَّر — وهو أسوأ ما يمكن أن يحدث: تقدير معقول بدل رقم مختلق.
|
||||
*/
|
||||
static decide(params: {
|
||||
actualKm: number;
|
||||
routedKm: number;
|
||||
points: number;
|
||||
tolerance?: number;
|
||||
minPoints?: number;
|
||||
}): BillableDistance {
|
||||
const tolerance = params.tolerance ?? 1.25;
|
||||
const minPoints = params.minPoints ?? 5;
|
||||
const routedKm = Math.max(0, Number(params.routedKm) || 0);
|
||||
const actualKm = Math.max(0, Number(params.actualKm) || 0);
|
||||
const ratio = routedKm > 0 ? Number((actualKm / routedKm).toFixed(3)) : 0;
|
||||
|
||||
if (params.points < minPoints || actualKm <= 0) {
|
||||
return {
|
||||
billableKm: routedKm,
|
||||
actualKm,
|
||||
routedKm,
|
||||
ratio,
|
||||
capped: false,
|
||||
points: params.points,
|
||||
source: 'route',
|
||||
};
|
||||
}
|
||||
|
||||
const ceiling = routedKm * tolerance;
|
||||
const capped = actualKm > ceiling && routedKm > 0;
|
||||
const billableKm = Number(Math.min(actualKm, capped ? ceiling : actualKm).toFixed(3));
|
||||
|
||||
return {
|
||||
billableKm,
|
||||
actualKm,
|
||||
routedKm,
|
||||
ratio,
|
||||
capped,
|
||||
points: params.points,
|
||||
source: 'gps',
|
||||
};
|
||||
}
|
||||
|
||||
/** يجمع القراءة والقرار — ما تناديه تسوية الأجرة. */
|
||||
async billableFor(params: {
|
||||
tenantId: string;
|
||||
driverId: string;
|
||||
from: Date;
|
||||
to: Date;
|
||||
routedKm: number;
|
||||
tolerance?: number;
|
||||
}): Promise<BillableDistance> {
|
||||
const { km, points } = await this.actualDistanceKm(
|
||||
params.tenantId,
|
||||
params.driverId,
|
||||
params.from,
|
||||
params.to,
|
||||
);
|
||||
const decision = TripDistanceService.decide({
|
||||
actualKm: km,
|
||||
routedKm: params.routedKm,
|
||||
points,
|
||||
tolerance: params.tolerance,
|
||||
});
|
||||
if (decision.capped) {
|
||||
this.logger.warn(
|
||||
`distance capped: driver=${params.driverId} actual=${decision.actualKm} ` +
|
||||
`routed=${decision.routedKm} ratio=${decision.ratio} → billed ${decision.billableKm}`,
|
||||
);
|
||||
}
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,13 @@ import { UsersModule } from '../users/users.module';
|
||||
import { LocationsModule } from '../locations/locations.module';
|
||||
import { CreditModule } from '../credit/credit.module';
|
||||
import { GeofenceModule } from '../geofence/geofence.module';
|
||||
import { RewardsModule } from '../rewards/rewards.module';
|
||||
import { TripDistanceService } from './trip-distance.service';
|
||||
import { DriverTrack } from '../locations/entities/driver-track.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Trip, TripEvent]),
|
||||
TypeOrmModule.forFeature([Trip, TripEvent, DriverTrack]),
|
||||
MapsModule,
|
||||
TariffModule,
|
||||
MatchingModule,
|
||||
@@ -32,9 +35,10 @@ import { GeofenceModule } from '../geofence/geofence.module';
|
||||
LocationsModule,
|
||||
CreditModule,
|
||||
GeofenceModule,
|
||||
RewardsModule,
|
||||
],
|
||||
controllers: [TripsController],
|
||||
providers: [TripsService, TripStateService, ScheduledTripsSweeper],
|
||||
providers: [TripsService, TripStateService, ScheduledTripsSweeper, TripDistanceService],
|
||||
exports: [TripsService],
|
||||
})
|
||||
export class TripsModule {}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TripEvent } from './entities/trip-event.entity';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
import { TariffService } from '../tariff/tariff.service';
|
||||
import { TariffEngine } from '../tariff/tariff.engine';
|
||||
import { TariffDefinition } from '../tariff/entities/tariff.entity';
|
||||
import { MatchingService } from '../matching/matching.service';
|
||||
import { DriversService } from '../drivers/drivers.service';
|
||||
import { RealtimeGateway } from '../../realtime/realtime.gateway';
|
||||
@@ -59,9 +60,14 @@ export interface RequestTripDto {
|
||||
rider_id?: string; // يُستخدم من dispatch لإنشاء رحلة نيابةً عن راكب
|
||||
stops?: Array<{ lat: number; lng: number; label?: string }>; // docs/17 — B4
|
||||
scheduled_at?: string; // ISO — حجز مسبق (docs/17 — B5)
|
||||
payment_method?: string;
|
||||
coupon_code?: string; // كوبون خصم — يُقيَّم الآن ويُستهلك عند التسوية (L)
|
||||
}
|
||||
|
||||
import { GeofenceService } from '../geofence/geofence.service';
|
||||
import { CouponsService } from '../rewards/coupons.service';
|
||||
import { ReferralsService } from '../rewards/referrals.service';
|
||||
import { TripDistanceService } from './trip-distance.service';
|
||||
|
||||
@Injectable()
|
||||
export class TripsService {
|
||||
@@ -83,6 +89,9 @@ export class TripsService {
|
||||
private readonly locations: DriverLocationService,
|
||||
private readonly credit: DriverCreditService,
|
||||
private readonly geofence: GeofenceService,
|
||||
private readonly coupons: CouponsService,
|
||||
private readonly referrals: ReferralsService,
|
||||
private readonly distances: TripDistanceService,
|
||||
) {}
|
||||
|
||||
get(tenantId: string, id: string): Promise<Trip | null> {
|
||||
@@ -199,6 +208,22 @@ export class TripsService {
|
||||
this.logger.warn(`no tariff for ${city}/${serviceClass} — trip without quote`);
|
||||
}
|
||||
|
||||
// الكوبون يُقيَّم الآن ليعرف الراكب سعره قبل التأكيد، لكنه **لا يُستهلك**
|
||||
// هنا: رحلة قد تُلغى، واستهلاكه عند الطلب كان يحرق الكود بلا رحلة.
|
||||
// الاستهلاك في `settleFare` وحدها.
|
||||
let couponCode: string | null = null;
|
||||
if (dto.coupon_code && quotedFare != null) {
|
||||
const evaluated = await this.coupons.evaluate(
|
||||
tenantId,
|
||||
riderId,
|
||||
dto.coupon_code,
|
||||
quotedFare,
|
||||
currency ?? 'JOD',
|
||||
);
|
||||
if (evaluated.ok) couponCode = evaluated.coupon!.code;
|
||||
else this.logger.warn(`coupon ${dto.coupon_code} rejected at request: ${evaluated.reason}`);
|
||||
}
|
||||
|
||||
let trip = this.trips.create({
|
||||
tenant_id: tenantId,
|
||||
rider_id: riderId,
|
||||
@@ -223,7 +248,8 @@ export class TripsService {
|
||||
commission_amount: commissionAmount,
|
||||
commission_rate: commissionRate,
|
||||
currency: currency ?? undefined,
|
||||
payment_method: (dto as any).payment_method ?? 'cash',
|
||||
payment_method: dto.payment_method ?? 'cash',
|
||||
coupon_code: couponCode,
|
||||
});
|
||||
trip = await this.trips.save(trip);
|
||||
|
||||
@@ -427,15 +453,40 @@ export class TripsService {
|
||||
}
|
||||
}
|
||||
|
||||
// تأهيل الإحالات عند الإنهاء (المجموعة L — L4): المكافأة تُستحقّ برحلة
|
||||
// مكتملة لا بتسجيل. الطرفان يُفحصان لأن الاتجاهات أربعة — قد يكون
|
||||
// المُحال هو الراكب أو السائق.
|
||||
if (toStatus === 'completed') {
|
||||
for (const userId of [trip.rider_id, snapshot.driver_user_id]) {
|
||||
if (!userId) continue;
|
||||
try {
|
||||
await this.referrals.onTripCompleted(tenantId, userId, trip.id);
|
||||
} catch (e: any) {
|
||||
// فشل المكافأة يجب ألّا يُسقط إنهاء الرحلة — الماسح يلتقطها لاحقاً.
|
||||
this.logger.error(`referral qualify failed for ${userId}: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// تسوية المحفظة عند الدفع (payment_method === wallet): المبلغ ينتقل من
|
||||
// الراكب للسائق **كاملاً**. العمولة لا تُقتطع هنا — خُصمت من الرصيد
|
||||
// التشغيلي عند الإنهاء (docs/18 §5.5: قاعدة واحدة للكاش والمحفظة).
|
||||
if (toStatus === 'paid' && trip.payment_method === 'wallet' && trip.final_fare != null) {
|
||||
const fare = Number(trip.price_for_passenger ?? trip.final_fare);
|
||||
// الطرفان لم يعودا متساويين بعد الكوبون: الراكب يدفع المخفَّض والسائق
|
||||
// يقبض الكامل. الفرق تتحمّله المنصة — ولذلك **لا يجوز** اشتقاق أحدهما
|
||||
// من الآخر هنا، فالسائق كان سيخسر خصماً لم يوافق عليه.
|
||||
const riderPays = Number(trip.price_for_passenger ?? trip.final_fare);
|
||||
const driverGets = Number(trip.price_for_driver ?? trip.final_fare);
|
||||
try {
|
||||
await this.wallet.debit(tenantId, trip.rider_id, fare, 'trip_fare', trip.id);
|
||||
if (snapshot.driver_user_id && fare > 0) {
|
||||
await this.wallet.credit(tenantId, snapshot.driver_user_id, fare, 'trip_earning', trip.id);
|
||||
await this.wallet.debit(tenantId, trip.rider_id, riderPays, 'trip_fare', trip.id);
|
||||
if (snapshot.driver_user_id && driverGets > 0) {
|
||||
await this.wallet.credit(
|
||||
tenantId,
|
||||
snapshot.driver_user_id,
|
||||
driverGets,
|
||||
'trip_earning',
|
||||
trip.id,
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`wallet settle failed: ${e?.message}`);
|
||||
@@ -659,15 +710,33 @@ export class TripsService {
|
||||
// الانتظار ومشوار الوصول حُسبا وخُزّنا سابقاً — نقرأهما من الصف.
|
||||
const row = await this.trips.findOne({
|
||||
where: { tenant_id: tenantId, id: tripId },
|
||||
select: { waiting_charge: true, pickup_charge: true, is_destination_match: true },
|
||||
select: {
|
||||
waiting_charge: true,
|
||||
pickup_charge: true,
|
||||
is_destination_match: true,
|
||||
coupon_code: true,
|
||||
rider_id: true,
|
||||
currency: true,
|
||||
id: true,
|
||||
driver_id: true,
|
||||
started_at: true,
|
||||
completed_at: true,
|
||||
distance_km: true,
|
||||
duration_min: true,
|
||||
},
|
||||
});
|
||||
const base = snapshot.quoted_fare ?? 0;
|
||||
const waiting = Number(row?.waiting_charge ?? 0);
|
||||
// مشوار الوصول لا يدخل أجرة الرحلة — هو تعويض إلغاء فقط (docs/18، تصحيح B3).
|
||||
const gross = base + waiting;
|
||||
|
||||
const def = await this.tariffDef(tenantId, snapshot);
|
||||
|
||||
// إعادة التسعير على **المسافة المقطوعة فعلاً** لا على تقدير وقت الطلب
|
||||
// (نظير `car_tracks` عند سيرو). تحويلة حقيقية يقبضها السائق، وجولة
|
||||
// مخترَعة يقصّها السقف. التفاصيل في `TripDistanceService`.
|
||||
const priced = await this.repriceOnActualDistance(tenantId, def, row, snapshot);
|
||||
|
||||
const waiting = Number(row?.waiting_charge ?? 0);
|
||||
// مشوار الوصول لا يدخل أجرة الرحلة — هو تعويض إلغاء فقط (docs/18، تصحيح B3).
|
||||
const gross = priced.fare + waiting;
|
||||
|
||||
// خصم مطابقة الوجهة (docs/17 — B8): الرحلة توافق اتجاه السائق فيُخفَّض
|
||||
// على الراكب. الخصم من أجرة الراكب لا من دخل السائق — السائق يقبض الأجرة
|
||||
// المخفَّضة كاملة (يربح رحلةً في طريقه أصلاً).
|
||||
@@ -677,20 +746,103 @@ export class TripsService {
|
||||
}
|
||||
const total = Number((gross - discount).toFixed(3));
|
||||
|
||||
// العمولة على الأجرة **قبل** خصم الكوبون: الكوبون تنازل تسويقي من
|
||||
// المنصة، فاحتسابه بعده كان يجعل المنصة تدفع الخصم مرتين — مرة للراكب
|
||||
// ومرة بعمولة أنقص من السائق.
|
||||
const commission = def ? TariffEngine.commission(def, total) : { amount: 0, rate: 0 };
|
||||
|
||||
// الكوبون آخر خطوة، وعلى جانب الراكب وحده (المجموعة L). الاستهلاك هنا
|
||||
// لا عند الاقتباس: الاقتباس يتكرر مع كل تغيير وجهة، والاستهلاك مرة.
|
||||
let couponDiscount = 0;
|
||||
if (row?.coupon_code) {
|
||||
couponDiscount = await this.coupons.redeem(
|
||||
tenantId,
|
||||
row.rider_id,
|
||||
row.coupon_code,
|
||||
tripId,
|
||||
total,
|
||||
row.currency ?? 'JOD',
|
||||
);
|
||||
}
|
||||
const riderPays = Number((total - couponDiscount).toFixed(3));
|
||||
|
||||
return {
|
||||
final_fare: total,
|
||||
destination_match_discount: discount || null,
|
||||
// السائق يقبض ما يدفعه الراكب **كاملاً** (docs/18). العمولة تُخصم من
|
||||
// رصيده التشغيلي لا من هذا المبلغ.
|
||||
price_for_passenger: total,
|
||||
coupon_discount: couponDiscount || null,
|
||||
actual_distance_km: priced.actualKm,
|
||||
billed_distance_km: priced.billedKm,
|
||||
distance_ratio: priced.ratio,
|
||||
// الراكب يدفع بعد الكوبون، والسائق يقبض الأجرة كاملة **قبله**: الخصم
|
||||
// تكلفة تسويق على المنصة لا اقتطاع من دخل السائق (docs/18 §52).
|
||||
// والعمولة تُخصم من رصيده التشغيلي لا من هذا المبلغ (docs/18).
|
||||
price_for_passenger: riderPays,
|
||||
price_for_driver: total,
|
||||
commission_amount: commission.amount,
|
||||
commission_rate: commission.rate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* يعيد تسعير الرحلة على المسافة المقطوعة فعلاً (docs/17 — B12).
|
||||
*
|
||||
* **لا يفشل أبداً**: كل مسار خطأ يعود للسعر المقفول وقت الطلب. الرحلة تمّت
|
||||
* والراكب واقف ينتظر فاتورته — تعذّر قراءة نبضات GPS يجب ألّا يترك رحلة
|
||||
* بلا أجرة. وسعر الطلب رقم معلن سبق أن رآه الراكب، فهو أسلم رجعة ممكنة.
|
||||
*/
|
||||
private async repriceOnActualDistance(
|
||||
tenantId: string,
|
||||
def: TariffDefinition | null | undefined,
|
||||
row: Partial<Trip> | null,
|
||||
snapshot: TripState,
|
||||
): Promise<{ fare: number; actualKm: number | null; billedKm: number | null; ratio: number | null }> {
|
||||
const quoted = snapshot.quoted_fare ?? 0;
|
||||
const fallback = { fare: quoted, actualKm: null, billedKm: null, ratio: null };
|
||||
|
||||
// السعر الثابت لا يُعاد تسعيره بالمسافة — هذا معنى «ثابت» (المسافة
|
||||
// تُقاس وتُخزَّن للتدقيق، لكنها لا تمسّ الأجرة).
|
||||
if (!def || def.mode === 'fixed_quote') return fallback;
|
||||
if (!row?.driver_id || !row.started_at || !row.completed_at) return fallback;
|
||||
|
||||
const routedKm = Number(row.distance_km ?? 0);
|
||||
if (!(routedKm > 0)) return fallback;
|
||||
|
||||
try {
|
||||
const decision = await this.distances.billableFor({
|
||||
tenantId,
|
||||
driverId: row.driver_id,
|
||||
from: new Date(row.started_at),
|
||||
to: new Date(row.completed_at),
|
||||
routedKm,
|
||||
});
|
||||
if (decision.source === 'route') {
|
||||
// نبضات أقل من أن يُبنى عليها مسار — نُبقي السعر المقفول ونسجّل ما
|
||||
// قِيس، فالرقم يبقى للتدقيق حتى حين لا يُحاسَب عليه.
|
||||
return { ...fallback, actualKm: decision.actualKm, ratio: decision.ratio };
|
||||
}
|
||||
|
||||
// الزمن الفعلي من الانطلاق للإنهاء — لا تقدير المزوّد. الزحمة الحقيقية
|
||||
// جزء من الأجرة في وضع الزمن+المسافة.
|
||||
const actualMin =
|
||||
(new Date(row.completed_at).getTime() - new Date(row.started_at).getTime()) / 60000;
|
||||
|
||||
const q = TariffEngine.quote(def, {
|
||||
distanceKm: decision.billableKm,
|
||||
durationMin: Math.max(0, Number(actualMin.toFixed(1))),
|
||||
at: new Date(row.started_at),
|
||||
});
|
||||
return {
|
||||
fare: q.total,
|
||||
actualKm: decision.actualKm,
|
||||
billedKm: decision.billableKm,
|
||||
ratio: decision.ratio,
|
||||
};
|
||||
} catch (e: any) {
|
||||
this.logger.error(`reprice failed for trip ${row?.id ?? 'unknown'}: ${e?.message}`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private assertTransition(from: TripStatus, to: TripStatus) {
|
||||
if (!(TRANSITIONS[from] ?? []).includes(to)) {
|
||||
throw new BadRequestException(`Invalid transition ${from} -> ${to}`);
|
||||
|
||||
@@ -277,6 +277,67 @@
|
||||
|
||||
---
|
||||
|
||||
## المجموعة L — الإحالات والمكافآت والكوبونات — ✅ منفَّذة
|
||||
> قرار المالك 2026-07-18. **قانونان ثابتان يحكمان المجموعة كلها:**
|
||||
> 1. **محفظة الراكب لا تُسحب أبداً** — فمكافأة الراكب **كوبون خصم** لا رصيد محفظة. إنزالها في المحفظة كان يخلق التزاماً نقدياً قابلاً للسحب مقابل حملة تسويقية.
|
||||
> 2. **كل خصم مسقوف بعملته** — 25 جنيهاً في مصر · 25 ليرة سورية · 0.25 دينار (نفس نسبة `credit-policy`). السقف **حدّ أعلى مطلق** لا افتراض.
|
||||
|
||||
| # | البند | الحالة |
|
||||
|---|-------|--------|
|
||||
| L1 | `referral_codes` (كود شخصي ثابت) + `referrals` بحالات `pending → qualified → paid` | ✅ |
|
||||
| L2 | نوع قيد `referral_bonus` في `credit_txns` + `grantReferralBonus` بحارسَي J6 (فحص تطبيقي + فهرس فريد جزئي على `ref`) | ✅ |
|
||||
| L3 | محرّك الكوبونات: `coupons` + `coupon_redemptions`. يخصم من `price_for_passenger` وحده — `price_for_driver` يبقى كاملاً (docs/18 §52) | ✅ |
|
||||
| L4 | **شرط الاستحقاق**: بعد أول رحلة مكتملة للمُحال لا عند التسجيل. `qualifying_trips` لا ينزل تحت 1 ولو ضبطه الأدمن صفراً | ✅ |
|
||||
| L5 | `RewardsSweeper` — نمط `ScheduledTripsSweeper` (setInterval + قفل `running`). **شبكة أمان لا مسار أساسي**: الصرف يقع لحظة التأهّل | ✅ |
|
||||
| L6 | `tenants.settings.rewards` بنفس بنية `credit-policy` — سقف الخصم ومكافآت الطرفين لكل عملة | ✅ |
|
||||
| L7 | ربط `fraud.service` — كشف حلقات الإحالة والأجهزة المكرّرة | ⏳ |
|
||||
| L8 | باقات الشحن (J5) فوق نفس المحرّك | ⏳ |
|
||||
|
||||
**اتجاهات الإحالة الأربعة ليست أربعة مسارات**: شكل المكافأة يتبع **دور المستفيد** لا اتجاه الإحالة — سائق ← رصيد تشغيلي، راكب ← كوبون. والدور يُحسم **وقت الصرف** من وجود صفّ سائق لا من الدور المخزَّن وقت التسجيل، فمن سجّل بكود دعوة ثم تقدّم سائقاً يستحقّ رصيداً لا كوبوناً.
|
||||
|
||||
**حرّاس الاحتيال (على القاعدة لا بالفحص التطبيقي وحده)**:
|
||||
- فريد على `(tenant_id, referee_user_id)` — الشخص يُحال **مرة واحدة** في عمره. بدونه تُعاد نفس الضحية تحت عشرة داعين.
|
||||
- `CHECK (referrer <> referee)` — إحالة النفس.
|
||||
- فريد جزئي على `credit_txns(tenant_id, ref) WHERE type='referral_bonus'` وعلى `coupons(tenant_id, ref)` — الماسح والصرف الفوري قد يلتقيان على إحالة واحدة.
|
||||
- فريد على `coupon_redemptions(tenant_id, trip_id)` — كوبون واحد لكل رحلة.
|
||||
|
||||
**درس مثبَّت باختبار**: المحاولة الأولى حسبت عتبة التأهّل داخل SQL (`CASE WHEN qualifying_trips + 1 >= n`)، فجُمع العمود **نصّاً** (`'0' + 1 = '01'`) ولم تتحقق الشرطية أبداً — كل إحالة تبقى معلّقة بلا مكافأة، وهو فشل صامت لا يُكتشف إلا بشكوى مستخدم. الحساب صار في TypeScript عبر `RETURNING`، والتزامن محفوظ بشرط الحالة في كلتا العبارتين.
|
||||
|
||||
**العمولة تُحتسب قبل خصم الكوبون** عمداً: الكوبون تنازل تسويقي من المنصة، فاحتسابها بعده كان يجعل المنصة تدفع الخصم مرتين — مرة للراكب ومرة بعمولة أنقص من السائق.
|
||||
|
||||
---
|
||||
|
||||
## المجموعة M — أساس التسعير: تعرفة الانطلاق والمسافة الحقيقية — ✅ منفَّذة
|
||||
> قرار المالك 2026-07-19. **الحدّ الأدنى للرحلة: 1.10 دينار · 20 جنيهاً · 150 ليرة** — وهذه المرساة التي تُشتقّ منها كل الأرقام الأخرى.
|
||||
|
||||
| # | البند | الحالة |
|
||||
|---|-------|--------|
|
||||
| M1 | `default-tariffs.ts` — مصدر حقيقة واحد يستهلكه `SeedService` ومولّد الـSQL معاً | ✅ |
|
||||
| M2 | `city = 'default'` بدل اسم البلد؛ البلد من `country_pack` للمستأجر | ✅ |
|
||||
| M3 | نوافذ التعرفة **بالتوقيت المحلي** (`timezone` في التعريف) عبر `Intl` — لا UTC ولا توقيت الخادم | ✅ |
|
||||
| M4 | فتحة العدّاد (`flag`) حقيقية في كل نافذة، ورسم حجز (`booking_fee`) | ✅ |
|
||||
| M5 | `fixed_quote` منفَّذ فعلاً — لا مسافة ولا زمن ولا حدّ أدنى؛ الانتظار وحده يُضاف | ✅ |
|
||||
| M6 | الفئات مشتقّة بمعاملات: أوفر < اقتصادي < كهربائي < ليدي < مريح < فان < VIP | ✅ |
|
||||
| M7 | `TripDistanceService` — المسافة من نبضات GPS مع تنقية الضجيج والقفزات | ✅ |
|
||||
| M8 | إعادة التسعير عند الإنهاء على المسافة المقطوعة فعلاً، بسقف `routed × 1.25` | ✅ |
|
||||
| M9 | زرع التعرفة **لكل فئة خدمة** لا الاقتصادي وحده | ✅ |
|
||||
| M10 | سماحية المسافة لكل مستأجر + ربط الانحراف بـ`fraud.service` | ⏳ |
|
||||
|
||||
**الأعطال التي أُصلحت (كلها كانت فشلاً صامتاً)**:
|
||||
- **رحلة مجانية**: السكربت القديم كتب `city='jordan'`، والكود يبحث بـ`'default'` → لا تعرفة → `quotedFare = null` → `settleFare` تأخذ `quoted_fare ?? 0` → **أجرة صفر وعمولة صفر**، بسطر تحذير واحد في اللوج.
|
||||
- **ذروة لا تُحصَّل أبداً**: `pickWindow` قرأ `getUTCHours()`، فذروة 16:00–20:00 تقع 19:00–23:00 في عمّان.
|
||||
- **الفان أرخص من الاقتصادي** في مصر وسوريا (22 مقابل 29.5 جنيهاً لنفس الرحلة) — أي أن الخيار العقلاني لكل راكب هو أغلى فئة على المشغّل.
|
||||
- **حدّ أدنى ثلث الحقيقي**: `perKm × 2` = 0.368 ديناراً بدل 1.10.
|
||||
- **`fixed_quote` لم يكن ثابتاً**: كان يسقط للفرع المتري فيُحسب بالعدّاد رغم اسمه.
|
||||
- **السكربت لم يعمل أصلاً**: `require` في ملف ESM؛ ولا وجود لـ`seed-tariffs.sql` إطلاقاً.
|
||||
- **تشغيلتان = تعرفتان فعّالتان** بنفس النسخة، و`getActive` ترتّب `version DESC` فالاختيار بينهما غير محدَّد.
|
||||
|
||||
**سياسة المسافة (M7/M8)**: يُحاسَب **الأطول** من (المقاس، المقدَّر) بسقف `routed × 1.25`. التحويلة الحقيقية يقبضها السائق، والجولة المخترَعة تُقصّ وتُعلَّم للمراجعة لا تُخصم صامتة. والطريق الأقصر يُحاسَب بالأقصر — الراكب لا يدفع تقديراً لم يُقطع. نبضات أقل من 5 = رجوع للسعر المقفول: رقم مخترَع من الضجيج أسوأ من تقدير معقول.
|
||||
|
||||
**تصحيح على المجموعة L**: سقوف الكوبونات كانت مبنية على نسبة `credit-policy` القديمة (1:100:100)، وهي لا تطابق مرساة المالك (1.10 = 20 = 150، أي 1 دينار ≈ 18.2 جنيهاً ≈ 136 ليرة). السقف 25 جنيهاً صار يقابل **1.375 ديناراً و190 ليرة** بدل 0.25 و25 — النسخة الأولى كانت تعطي سوريا سُدس العرض.
|
||||
|
||||
---
|
||||
|
||||
## مؤجَّل عمداً (قرار المالك)
|
||||
المفاوض الذكي · تدرّج السائق · خصم العمولة — **آخر شيء** (جديدة حتى على سيرو).
|
||||
**Geofence** — مؤجَّل («لوقتها»)، موجود في سيرو للاستئناس.
|
||||
|
||||
@@ -116,6 +116,9 @@ Shorebird يرقّع Dart فقط. بتوحيد Kotlin/الصلاحيات/NDK ت
|
||||
| N4 | **لوحة أدمن المستأجر (ويب)**: سائقون (اعتماد) · رحلات · مراجعة وثائق · سحوبات (تحويل/فشل). | ✅ `dashboards/admin-web/index.html` + نقاط سرد إدارية (`/drivers/admin/list` · `/trips/admin/list` · `/payouts/admin/list` خلف RolesGuard admin/dispatcher). التعرفة والتقارير تُضاف مع L. |
|
||||
| N5 | **لوحة خدمة العملاء (ويب)**: بحث مستخدم برقمه (فهرس أعمى) · رحلاته · تفاصيل رحلة بالمعرّف. | ✅ `dashboards/service-web/index.html` + `GET /admin/users/search` · `/trips/admin/list?rider=`. الشكاوى تحتاج وحدة complaints (لا توجد بعد — بند لاحق). |
|
||||
|
||||
| N6 | **كتالوج تسعير الميزات** (قرار المالك 2026-07-19): جدول `feature_catalog` — المفتاح · الاسم العربي · الفئة (نواة/باقة/add-on) · **سعر شهري** · رسم إعداد · الوصف — والسوبر-أدمن يحرّر الأسعار من اللوحة. يوسّع K2: الكتالوج نفسه يغذّي الاستحقاقات الافتراضية والتسعير معاً. الإضافات المسعّرة أولاً: استخبار السوق · البوتات/التسويق · التسعير الديناميكي · المواصلات · المفاوض · شرائح السائقين · تأمين السائق. | ⬜ |
|
||||
| N7 | **مولّد عرض السعر (الفوترة التعاقدية)**: في لوحة السوبر-أدمن — اختيار باقة + إضافات ⇒ عرض سعر مجمّع (إعداد مرة واحدة + شهري) يُطبع/يُرسل للعميل عند التعاقد. عند الاعتماد: تُكتب `tenants.features` آلياً من البنود المختارة (تكامل K3 — لا إدخال يدوي مزدوج ينسى ميزة) + سجل اشتراك شهري يظهر في overview (N1b). | ⬜ |
|
||||
|
||||
**مصادقة اللوحتين**: هاتف + OTP → JWT بدور admin/dispatcher (نفس تدفّق الموبايل). كل الحماية على السيرفر (RolesGuard) — الواجهة عرض فقط. الوصول للوحة معيَّن بـ`?tenant=<slug>`.
|
||||
**اختبار الحراسة**: E2E يثبت أن السائق/الراكب (غير أدمن) يُرفضان من كل النقاط الإدارية (403).
|
||||
|
||||
@@ -156,6 +159,7 @@ Shorebird يرقّع Dart فقط. بتوحيد Kotlin/الصلاحيات/NDK ت
|
||||
| Q3 | **السائق**: overlay العرض · قبول · widgets الرحلة · **متحكّم الموقع** (تسجيل 3ث/رفع دفعات/واعٍ للبطارية — نمط سيرو) · حيوية الوجه · الرصيد/الشحن. |
|
||||
| Q4 | **الخرائط المتقدّمة**: خريطة حرارية · خريطة تنبّؤية (من O1). |
|
||||
| Q5 | **البنية التحتية للتطبيق**: طبقة الشبكة (توقيع HMAC · x-device-id) · country pack ديناميكي · flavors · **طبقتا الإعداد وأعلام `const` حسب §1.5** · موديولات feature-first تُسجَّل حسب العلم. |
|
||||
| Q6 | **نظام التصميم الثابت — [26-flutter-design-system](26-flutter-design-system.md)** (قرار المالك 2026-07-19): tokens · ثيم ليلي/نهاري من بذرة المستأجر · خطوط ثابتة (Inter + IBM Plex Sans Arabic مضمّنة محلياً) · اتجاه وخط يتبعان اللغة · `TripzScaffold` + عدة مكونات `core/ui/` · ARB للترجمة. **يُبنى قبل شاشات Q1–Q3** — ترتيب البناء في 26 §11. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **هذا المستند مُلزِم.** كل كود Dart في `apps/rider` و`apps/driver` يتبعه. أي انحراف يُصحَّح لا يُبرَّر.
|
||||
> اقرأه كاملاً قبل كتابة أول سطر في أي جلسة جديدة — هو العقد الذي يجعل جلستين مختلفتين تُنتجان نفس البنية.
|
||||
|
||||
آخر تحديث: 2026-07-18. ذو صلة: [22 §1.5](22-full-product-roadmap.md) (الطبقات) · [19](19-entitlements-licensing.md) (الاستحقاقات) · [[tripz-mobile-store-identity]].
|
||||
آخر تحديث: 2026-07-19. ذو صلة: [22 §1.5](22-full-product-roadmap.md) (الطبقات) · [19](19-entitlements-licensing.md) (الاستحقاقات) · **[26](26-flutter-design-system.md) (نظام التصميم الثابت — قانون الشكل المكمّل لهذا القانون)** · [[tripz-mobile-store-identity]].
|
||||
|
||||
---
|
||||
|
||||
@@ -28,7 +28,9 @@ apps/<rider|driver>/lib/
|
||||
│ ├── config.dart # إعداد وقت التشغيل (--dart-define)
|
||||
│ ├── di.dart # get_it: تسجيل التبعيات
|
||||
│ ├── router.dart # go_router: كل المسارات
|
||||
│ ├── theme.dart # الثيم من ألوان المستأجر
|
||||
│ ├── design/ # نظام التصميم (docs/26): tokens · typography · tripz_colors · theme
|
||||
│ ├── ui/ # عدة المكونات المشتركة (docs/26 §7): TripzScaffold · TripzButton …
|
||||
│ ├── l10n/ # ملفات ARB عربي/إنجليزي — كل نص مرئي يمرّ هنا
|
||||
│ ├── api/
|
||||
│ │ ├── api_client.dart # Dio + الاعتراضات (توكن · مستأجر · توقيع · جهاز)
|
||||
│ │ └── api_exception.dart
|
||||
@@ -141,7 +143,7 @@ if (Features.chat) routes.add(GoRoute(path: '/chat', builder: ...));
|
||||
| `firebase.json` · `firebase_options.dart` · `google-services.json` | مشروع FCM القائم |
|
||||
| `shorebird.yaml` | **app_id منشور** — تغييره يقطع تحديثات OTA عن المستخدمين الحاليين |
|
||||
| `key/` | **شهادات توقيع** — فقدانها يعني تعذّر تحديث التطبيق المنشور نهائياً |
|
||||
| الخطوط (mohanad · josefin · digit) | هوية بصرية قائمة |
|
||||
| ~~الخطوط (mohanad · josefin · digit)~~ | **نُسخ (2026-07-19)**: الخطوط الجديدة الثابتة Inter + IBM Plex Sans Arabic — القرار وأسبابه في [26 §1](26-flutter-design-system.md). الملفات القديمة تبقى حتى يؤكد المالك حذفها |
|
||||
|
||||
**معرّفات المتجر** في [[tripz-mobile-store-identity]] — تُنسخ ولا تُكتب من الذاكرة.
|
||||
⚠️ معرّفا السائق متضاربان بين المنصّتين (`com.sefer_driver` مقابل `com.sefer.driver`) — **يؤكّدهما المالك قبل أي إصدار**.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# 26 — نظام التصميم الثابت (الهوية البصرية لتطبيقات فلاتر)
|
||||
|
||||
> **هذا المستند مُلزِم** ويكمّل [23-flutter-conventions](23-flutter-conventions.md): ذاك قانون *الكود*، وهذا قانون *الشكل*.
|
||||
> الهدف: كل مستأجر يأخذ تطبيقاً يبدو مصمَّماً له، بينما هو نفس النظام حرفياً — المتغيّر لونه ولوغوه فقط.
|
||||
|
||||
آخر تحديث: 2026-07-19. ذو صلة: [22 §1.5](22-full-product-roadmap.md) (الطبقات) · [06](06-tenant-model.md) (المستأجرون).
|
||||
|
||||
---
|
||||
|
||||
## 0. العقد الحاكم: الثابت مقابل المتغيّر
|
||||
|
||||
| **ثابت عبر كل المستأجرين والطبقات** | **متغيّر لكل مستأجر (من BuildConfig — يولّده N3)** |
|
||||
|---|---|
|
||||
| الخطوط (عربي + إنجليزي + أرقام) | اللون البذرة (seed) الأساسي والثانوي |
|
||||
| المسافات والزوايا والظلال والحركة | اللوغو والأيقونة والـsplash |
|
||||
| بنية الشاشات (TripzScaffold) وعدة المكونات | اسم التطبيق |
|
||||
| سلوك الليلي/النهاري و RTL/LTR | — |
|
||||
| فلسفة التصميم وأنماط الحالات | — |
|
||||
|
||||
**القاعدة**: أي شيء غير الأعمدة الثلاثة (لون · لوغو · اسم) يتغيّر بين مستأجرين = خطأ يُصحَّح.
|
||||
السبب تشغيلي قبل أن يكون جمالياً: نظام واحد ثابت = شاشة تُبنى مرة وتخدم كل المستأجرين، وShorebird يرقّعها للجميع دفعة واحدة.
|
||||
|
||||
---
|
||||
|
||||
## 1. الخطوط — قرار 2026-07-19
|
||||
|
||||
المالك طلب «خط الآيفون». خط آبل هو **SF Pro** وهو مملوك لآبل ولا يجوز تضمينه قانونياً في تطبيق أندرويد. البديل المطابق المعتمد عالمياً من Google Fonts:
|
||||
|
||||
| الدور | الخط | لماذا |
|
||||
|---|---|---|
|
||||
| إنجليزي + أرقام | **Inter** | أقرب مطابق حرّ لـSF Pro (نفس الروح الهندسية)، أوزان كاملة، أرقام tabular للعدّادات والأسعار |
|
||||
| عربي | **IBM Plex Sans Arabic** | النظير العربي الأقرب لروح SF Arabic: حديث، محايد، أوزان كاملة، ممتاز في الأحجام الصغيرة |
|
||||
|
||||
**قواعد لا تُخالَف:**
|
||||
- الخطوط **تُضمَّن ملفاتٍ محلية** في `assets/fonts/` (نفس الملفات في الراكب والسائق). حزمة `google_fonts` بجلبها الشبكي **ممنوعة** — اتصال سوريا لا يُراهَن عليه، والشكل يجب أن يكون حتمياً من أول إقلاع.
|
||||
- الأوزان المضمّنة: Regular · Medium · SemiBold · Bold لكل خط (٨ ملفات). لا أوزان أخرى.
|
||||
- الأسعار والعدّادات والأرقام الحيّة تستعمل **Inter بأرقام tabular** (`FontFeature.tabularFigures`) كي لا «ترقص» الأعمدة عند تغيّر القيمة — هذا يُغني عن خط `digit` القديم.
|
||||
- خطوط التطبيق القديم (`mohanad` · `josefin` · `digit`) **متقاعدة** من التصميم الجديد. ملفاتها تبقى في المستودع حتى يؤكد المالك حذفها. *(هذا يُحدّث docs/23 §7 الذي كان يعتبرها هوية قائمة.)*
|
||||
- تبديل الخط مستقبلاً = تغيير سطرين في `typography.dart` — لا قرار مبعثر.
|
||||
|
||||
---
|
||||
|
||||
## 2. الألوان والثيم
|
||||
|
||||
- `ColorScheme.fromSeed` من لون المستأجر (`BuildConfig.primaryColor`) — نسختان light وdark تُشتقّان آلياً من نفس البذرة. **لا لون hex مكتوب في أي شاشة.**
|
||||
- الأدوار الدلالية غير الموجودة في ColorScheme تعيش في `ThemeExtension` واحد اسمه `TripzColors`: `success` · `warning` · `danger` · `info` · `surfaceRaised` · ألوان طبقات الخريطة. الشاشة تقول `context.tripzColors.success` ولا تقرّر درجة اللون بنفسها.
|
||||
- **الليلي ليس أسود صرفاً**: سطوح متدرّجة من الرمادي الداكن (elevation بالسطوع لا بالظل). الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر الغرض.
|
||||
- تباين النص على خلفيته **≥ 4.5:1** دائماً. البذرة مهما كانت فاتحة، `fromSeed` يضمن السطوح — والفحص جزء من قائمة تحقق الشاشة (§9).
|
||||
|
||||
## 3. الرموز الثابتة (tokens) — `core/design/tokens.dart`
|
||||
|
||||
كلها `const` في ملف واحد. الشاشة **لا تخترع رقماً**:
|
||||
|
||||
| المجموعة | السلّم |
|
||||
|---|---|
|
||||
| المسافات | مضاعفات 4: `4 · 8 · 12 · 16 · 24 · 32 · 48` — حشوة الصفحة الافتراضية **16** |
|
||||
| الزوايا | `8` (حقول/شارات) · `12` (أزرار/بطاقات) · `24` (bottom sheets) · دائري كامل (avatars) |
|
||||
| الحركة | `120ms` (لمسة) · `240ms` (انتقال ضمن الشاشة) · `400ms` (sheet/صفحة) — منحنى `easeOutCubic` موحّد |
|
||||
| الأيقونات | `20 · 24 · 32` — عائلة واحدة: Material Symbols Rounded |
|
||||
| الارتفاع | زر CTA `52` · حقل إدخال `52` · هدف لمس أدنى `48` |
|
||||
|
||||
## 4. الليلي/النهاري واللغة والاتجاه
|
||||
|
||||
- **الثيم**: الافتراضي `ThemeMode.system`، والمستخدم يستطيع تثبيت light/dark من الإعدادات. الاختيار يُحفظ محلياً (SharedPreferences — ليس سراً) عبر `SettingsCubit` في `features/settings/`.
|
||||
- **اللغة**: الافتراضي عربي، والإنجليزية مدعومة. الاختيار محفوظ بنفس الطريقة.
|
||||
- **الاتجاه يتبع اللغة آلياً** — MaterialApp يفعلها وحده من الـlocale. ⚠️ الموجود الآن في `app.dart` يفرض `Directionality.rtl` فوق كل شيء — **يُحذف**: مع الإنجليزية يجب أن ينقلب التطبيق LTR كاملاً.
|
||||
- **الخط يتبع اللغة**: الـTextTheme يُبنى حسب الـlocale — عربي ⇒ IBM Plex Sans Arabic والاحتياط Inter، إنجليزي ⇒ Inter والاحتياط Plex (كي لا تنكسر الكلمة العربية داخل جملة إنجليزية والعكس).
|
||||
- **الترجمة**: ARB + `gen_l10n` القياسية. لا نص مرئي داخل widget — يمرّ بـ`context.l10n` (يفعّل قاعدة docs/23 §2 التي كانت بلا أداة).
|
||||
- دالة بناء الثيم النهائية: `buildTheme(brightness, locale)` — المدخلان الوحيدان.
|
||||
|
||||
## 5. الأرقام والتاريخ والعملة
|
||||
|
||||
- **الأرقام لاتينية (1 2 3) دائماً** في اللغتين — عرف المنطقة في التطبيقات، وتناسق الأسعار والعدادات أهم من الأرقام الهندية.
|
||||
- العملة ورمزها وموضعها من **country pack** المستأجر عبر `intl` — لا تنسيق يدوي بـ`toString`.
|
||||
- التاريخ يُعرض بصيغة الـlocale، والزمن النسبي («قبل ٣ د») من طبقة الترجمة.
|
||||
|
||||
## 6. مستوى السكافولد — `TripzScaffold` (نقطة التنظيم المركزية)
|
||||
|
||||
كل شاشة تُبنى داخل `TripzScaffold` — **ممنوع `Scaffold` خام في `features/`**:
|
||||
|
||||
```dart
|
||||
TripzScaffold(
|
||||
title: 'رحلاتي', // يمر بالترجمة فعلياً
|
||||
status: state.status, // enum الـCubit كما هو
|
||||
onRetry: cubit.load, // زر إعادة المحاولة في حالة الخطأ
|
||||
empty: EmptyView(...), // حالة اللاشيء
|
||||
bottomAction: TripzButton(...), // خانة CTA السفلية (52px) — keyboard-safe
|
||||
child: ..., // المحتوى عند النجاح فقط
|
||||
)
|
||||
```
|
||||
|
||||
هو ما يجعل النظام **متوافقاً مع Cubit بالبنية لا بالاتفاق**: الـCubit يُصدر `status` من enum (قانون docs/23 §3)، والسكافولد يحوّله بنفسه إلى skeleton (تحميل) / ErrorView بزر إعادة (خطأ) / EmptyView (فراغ) / المحتوى (نجاح). الشاشة لا تكتب `if (loading)` أبداً — فتتوحّد حالات التطبيق كله من نقطة واحدة، وتغيير شكل «التحميل» لاحقاً سطرٌ واحد.
|
||||
|
||||
كما يوحّد: SafeArea · حشوة الصفحة (16) · شكل AppBar (مسطّح، بلا ظل، عنوان يسار-حسب-الاتجاه) · سلوك الكيبورد (الـCTA يعلو فوقه).
|
||||
|
||||
## 7. عدة المكونات — `core/ui/`
|
||||
|
||||
الشاشات **تركّب من هذه العدة ولا تستعمل ويدجت Material خاماً بستايل يدوي**:
|
||||
|
||||
| المكوّن | يغطي |
|
||||
|---|---|
|
||||
| `TripzButton` | primary (معبّأ 52px) · secondary (محدّد) · text — مع حالة تحميل داخلية |
|
||||
| `TripzTextField` | إدخال موحّد: هاتف · OTP · بحث — رسالة الخطأ تحته بالعربية |
|
||||
| `TripzSheet` | كل bottom sheet: مقبض علوي، زوايا 24، ارتفاعات محسوبة — **الحوار الافتراضي في التطبيق sheet لا dialog** |
|
||||
| `TripzCard` | بطاقة الرحلة/السائق/المحفظة |
|
||||
| `StatusBanner` | شريط حالة الرحلة الحي |
|
||||
| `EmptyView` · `ErrorView` · `Skeleton` | الحالات الثلاث — تستهلكها `TripzScaffold` |
|
||||
|
||||
ملف واحد = مكوّن واحد (قانون docs/23). مكوّن جديد يُضاف هنا أولاً ثم تستعمله الميزة — لا widget مشترك داخل `features/`.
|
||||
|
||||
## 8. فلسفة التصميم (المرجع عند كل خلاف ذوقي)
|
||||
|
||||
1. **الشاشة مهمة واحدة.** سؤال واحد للمستخدم، زر أساسي واحد في الأسفل. ما زاد نُقل لشاشة أو sheet.
|
||||
2. **الوضوح قبل الزينة.** لا تدرّجات ولا ظلال ثقيلة ولا زخرفة. الجمال من الفراغ والمحاذاة والخط — النموذج الذهني: تطبيقات آبل الأصلية.
|
||||
3. **منطقة الإبهام.** كل فعل أساسي في الثلث السفلي. أعلى الشاشة للمعلومة، أسفلها للفعل.
|
||||
4. **الحالة معروضة دائماً**: skeleton لا spinner فارغ، خطأ بالعربية مع «أعد المحاولة»، فراغ برسالة تدلّ على الفعل التالي. شاشة بيضاء صامتة = باغ.
|
||||
5. **الحركة وظيفة لا استعراض**: تشرح من-أين-إلى-أين ضمن أزمنة §3، وتُحترم إعدادات تقليل الحركة في النظام.
|
||||
|
||||
## 9. قائمة تحقّق الشاشة (تُضاف لقائمة docs/23 §12)
|
||||
|
||||
- [ ] داخل `TripzScaffold` وبحالات skeleton/error/empty تعمل فعلاً
|
||||
- [ ] لا لون hex ولا رقم مسافة/زاوية خارج tokens — ولا `Scaffold`/Material خام بستايل يدوي
|
||||
- [ ] تعمل بالعربية RTL وبالإنجليزية LTR (تُفحص باللغتين)
|
||||
- [ ] تعمل light وdark (تُفحص بالوضعين)
|
||||
- [ ] النصوص عبر `context.l10n`، الأرقام لاتينية، العملة من country pack
|
||||
- [ ] أهداف اللمس ≥ 48 وتكبير النص حتى 1.3 لا يكسر التخطيط
|
||||
|
||||
## 10. البنية في الكود (تحديث شجرة docs/23 §1)
|
||||
|
||||
```
|
||||
lib/core/
|
||||
├── design/
|
||||
│ ├── tokens.dart # المسافات/الزوايا/الحركة — const
|
||||
│ ├── typography.dart # TextTheme حسب الـlocale (Inter + Plex Arabic)
|
||||
│ ├── tripz_colors.dart # ThemeExtension للأدوار الدلالية
|
||||
│ └── theme.dart # buildTheme(brightness, locale) — يستبدل theme.dart الحالي
|
||||
├── ui/ # عدة المكونات (§7)
|
||||
└── l10n/ # ARB عربي/إنجليزي
|
||||
features/settings/ # SettingsCubit: الثيم + اللغة (محفوظان محلياً)
|
||||
```
|
||||
|
||||
## 11. ترتيب البناء (يسبق شاشات المجموعة Q)
|
||||
|
||||
1. **Q5-أ**: الخطوط (تنزيل الأوزان الثمانية للتطبيقين) · tokens · typography · TripzColors · buildTheme · حذف فرض RTL من `app.dart` · SettingsCubit.
|
||||
2. **Q5-ب**: `TripzScaffold` + عدة المكونات + ARB — ثم **تُعاد صياغة** شاشات auth/home القائمة عليها كإثبات.
|
||||
3. بعدها فقط تُبنى شاشات Q1/Q2/Q3 — كل شاشة جديدة تولد ملتزمة من أول سطر.
|
||||
Reference in New Issue
Block a user