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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 16:41:58 +03:00
co-authored by Claude Opus 4.8
parent 4397b07d2f
commit 00870a8cc3
26 changed files with 1358 additions and 1 deletions
+17
View File
@@ -4,10 +4,18 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import configuration from './config/configuration';
import { TenantMiddleware } from './common/tenant/tenant.middleware';
import { RedisModule } from './common/redis/redis.module';
import { SeedModule } from './common/seed/seed.module';
import { HealthModule } from './modules/health/health.module';
import { TenantsModule } from './modules/tenants/tenants.module';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
import { DriversModule } from './modules/drivers/drivers.module';
import { MatchingModule } from './modules/matching/matching.module';
import { TariffModule } from './modules/tariff/tariff.module';
import { MapsModule } from './modules/maps/maps.module';
import { TripsModule } from './modules/trips/trips.module';
import { RealtimeModule } from './realtime/realtime.module';
@Module({
imports: [
@@ -31,10 +39,19 @@ import { AuthModule } from './modules/auth/auth.module';
ThrottlerModule.forRoot([{ ttl: 60000, limit: 120 }]),
RedisModule, // عالمي — يوفّر عميل Redis للمطابقة و OTP لاحقاً
HealthModule,
TenantsModule,
UsersModule,
AuthModule,
MapsModule,
MatchingModule,
TariffModule,
DriversModule,
RealtimeModule,
TripsModule,
SeedModule,
],
})
export class AppModule implements NestModule {
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SeedService } from './seed.service';
import { TenantsModule } from '../../modules/tenants/tenants.module';
import { TariffModule } from '../../modules/tariff/tariff.module';
@Module({
imports: [TenantsModule, TariffModule],
providers: [SeedService],
})
export class SeedModule {}
+64
View File
@@ -0,0 +1,64 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { TenantsService } from '../../modules/tenants/tenants.service';
import { TariffService } from '../../modules/tariff/tariff.service';
/**
* seed تلقائي عند الإقلاع: مستأجر تجريبي (Tenant Zero) + تعرفة افتراضية،
* حتى لا نعيد إنشاءهما يدوياً بعد كل `docker compose down -v`.
*/
@Injectable()
export class SeedService implements OnModuleInit {
private readonly logger = new Logger('Seed');
constructor(
private readonly tenants: TenantsService,
private readonly tariff: TariffService,
) {}
async onModuleInit() {
try {
await this.run();
} catch (e: any) {
// على قاعدة جديدة قد تسبق onModuleInit تشغيلَ الهجرات — لا نُسقط الإقلاع.
this.logger.warn(`seed skipped (run migrations then restart api): ${e?.message ?? e}`);
}
}
private async run() {
let tenant = await this.tenants.findBySlug('siro');
if (!tenant) {
tenant = await this.tenants.create({
name: 'Siro (Demo — Tenant Zero)',
slug: 'siro',
countryPack: 'jo',
plan: 'brand',
});
this.logger.log(`seeded demo tenant "siro" (${tenant.id})`);
}
const existing = await this.tariff.getActive(tenant.id, 'default', 'economy');
if (!existing) {
await this.tariff.create({
tenant_id: tenant.id,
city: 'default',
service_class: 'economy',
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 },
},
});
this.logger.log('seeded default tariff for "siro"');
}
}
}
@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* جداول P1: drivers, tariffs, trips, trip_events (كلها ببادئة tripz_).
*/
export class InitP11721300000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
// drivers
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_drivers (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
user_id uuid NOT NULL,
service_class varchar NOT NULL DEFAULT 'economy',
vehicle_make varchar, vehicle_model varchar,
vehicle_plate varchar, vehicle_color varchar,
docs jsonb NOT NULL DEFAULT '{}',
verification_status varchar NOT NULL DEFAULT 'pending',
is_online boolean NOT NULL DEFAULT false,
rating numeric(3,2) NOT NULL DEFAULT 5,
last_lat double precision, last_lng double precision,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_drivers_tenant_user" ON tripz_drivers (tenant_id, user_id)`);
// tariffs
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_tariffs (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
city varchar NOT NULL,
service_class varchar NOT NULL,
definition jsonb NOT NULL,
version int NOT NULL DEFAULT 1,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_tariffs_lookup" ON tripz_tariffs (tenant_id, city, service_class, active)`);
// trips
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_trips (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
rider_id uuid NOT NULL,
driver_id uuid,
service_class varchar NOT NULL DEFAULT 'economy',
city varchar,
origin_lat double precision NOT NULL, origin_lng double precision NOT NULL,
dest_lat double precision NOT NULL, dest_lng double precision NOT NULL,
status varchar NOT NULL DEFAULT 'searching',
distance_km numeric(8,3), duration_min numeric(8,1),
tariff_id uuid, tariff_version int,
quoted_fare numeric(12,3), final_fare numeric(12,3),
currency varchar,
payment_method varchar NOT NULL DEFAULT 'cash',
requested_at timestamptz NOT NULL DEFAULT now(),
assigned_at timestamptz, completed_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_status" ON tripz_trips (tenant_id, status)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_rider" ON tripz_trips (tenant_id, rider_id)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trips_driver" ON tripz_trips (tenant_id, driver_id)`);
// trip_events
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_trip_events (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
trip_id uuid NOT NULL,
from_status varchar,
to_status varchar NOT NULL,
source varchar NOT NULL DEFAULT 'system',
payload jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
)`);
await q.query(`CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_events_trip" ON tripz_trip_events (tenant_id, trip_id)`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS tripz_trip_events`);
await q.query(`DROP TABLE IF EXISTS tripz_trips`);
await q.query(`DROP TABLE IF EXISTS tripz_tariffs`);
await q.query(`DROP TABLE IF EXISTS tripz_drivers`);
}
}
@@ -0,0 +1,43 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { DriversService } from './drivers.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('drivers')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('drivers')
export class DriversController {
constructor(private readonly drivers: DriversService) {}
@Post('apply')
apply(@CurrentUser() user: AuthUser, @Body() body: any) {
return this.drivers.apply(user.tenantId, user.userId, body);
}
@Get('me')
me(@CurrentUser() user: AuthUser) {
return this.drivers.findByUser(user.tenantId, user.userId);
}
@Patch('status')
setStatus(@CurrentUser() user: AuthUser, @Body('online') online: boolean) {
return this.drivers.setOnline(user.tenantId, user.userId, !!online);
}
@Post('location')
location(
@CurrentUser() user: AuthUser,
@Body('lat') lat: number,
@Body('lng') lng: number,
) {
return this.drivers.updateLocation(user.tenantId, user.userId, Number(lat), Number(lng));
}
// للأدمن/المشغّل — تبسيط P1: أي مستخدم مصادَق؛ يُقيَّد بدور لاحقاً.
@Patch(':id/approve')
approve(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.drivers.approve(user.tenantId, id);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Driver } from './entities/driver.entity';
import { DriversService } from './drivers.service';
import { DriversController } from './drivers.controller';
import { UsersModule } from '../users/users.module';
import { MatchingModule } from '../matching/matching.module';
@Module({
imports: [TypeOrmModule.forFeature([Driver]), UsersModule, MatchingModule],
controllers: [DriversController],
providers: [DriversService],
exports: [DriversService],
})
export class DriversModule {}
@@ -0,0 +1,93 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Driver } from './entities/driver.entity';
import { UsersService } from '../users/users.service';
import { MatchingService } from '../matching/matching.service';
@Injectable()
export class DriversService {
constructor(
@InjectRepository(Driver)
private readonly repo: Repository<Driver>,
private readonly users: UsersService,
private readonly matching: MatchingService,
) {}
findByUser(tenantId: string, userId: string): Promise<Driver | null> {
return this.repo.findOne({ where: { tenant_id: tenantId, user_id: userId } });
}
findById(tenantId: string, id: string): Promise<Driver | null> {
return this.repo.findOne({ where: { tenant_id: tenantId, id } });
}
/** تسجيل السائق بالوثائق — ينشئ سجل driver ويرفع دور المستخدم إلى driver. */
async apply(
tenantId: string,
userId: string,
data: Partial<Driver>,
): Promise<Driver> {
let driver = await this.findByUser(tenantId, userId);
if (!driver) {
driver = this.repo.create({
tenant_id: tenantId,
user_id: userId,
service_class: data.service_class ?? 'economy',
vehicle_make: data.vehicle_make,
vehicle_model: data.vehicle_model,
vehicle_plate: data.vehicle_plate,
vehicle_color: data.vehicle_color,
docs: data.docs ?? {},
verification_status: 'pending',
});
driver = await this.repo.save(driver);
}
// يصير دور المستخدم "driver" (يظهر في التوكن عند إعادة الدخول).
await this.users.setRole(tenantId, userId, 'driver');
return driver;
}
async approve(tenantId: string, driverId: string): Promise<Driver> {
const driver = await this.findById(tenantId, driverId);
if (!driver) throw new NotFoundException('Driver not found');
driver.verification_status = 'approved';
return this.repo.save(driver);
}
/** يبدّل حالة الاتصال؛ عند الاتصال يُضاف لفهرس GEO، وعند الفصل يُزال. */
async setOnline(
tenantId: string,
userId: string,
online: boolean,
): Promise<Driver> {
const driver = await this.findByUser(tenantId, userId);
if (!driver) throw new NotFoundException('Driver profile not found');
driver.is_online = online;
await this.repo.save(driver);
if (!online) {
await this.matching.removeDriver(tenantId, driver.id);
} else if (driver.last_lat != null && driver.last_lng != null) {
await this.matching.addDriver(tenantId, driver.id, driver.last_lat, driver.last_lng);
}
return driver;
}
/** تحديث الموقع الجاري: Redis GEO + لقطة في Postgres. */
async updateLocation(
tenantId: string,
userId: string,
lat: number,
lng: number,
): Promise<{ ok: boolean }> {
const driver = await this.findByUser(tenantId, userId);
if (!driver) throw new NotFoundException('Driver profile not found');
driver.last_lat = lat;
driver.last_lng = lng;
await this.repo.save(driver);
if (driver.is_online) {
await this.matching.addDriver(tenantId, driver.id, lat, lng);
}
return { ok: true };
}
}
@@ -0,0 +1,66 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export type VerificationStatus = 'pending' | 'approved' | 'rejected';
/**
* السائق = مستخدم بدور driver + بيانات مركبة ووثائق. الجدول: tripz_drivers.
* الموقع الجاري يعيش في Redis (المطابقة)؛ هنا نحفظ آخر لقطة فقط (docs/09).
*/
@Entity('drivers')
@Index(['tenant_id', 'user_id'], { unique: true })
export class Driver {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
user_id: string;
@Column({ default: 'economy' })
service_class: string;
@Column({ nullable: true })
vehicle_make: string;
@Column({ nullable: true })
vehicle_model: string;
@Column({ nullable: true })
vehicle_plate: string;
@Column({ nullable: true })
vehicle_color: string;
@Column({ type: 'jsonb', default: {} })
docs: Record<string, any>;
@Column({ type: 'varchar', default: 'pending' })
verification_status: VerificationStatus;
@Column({ default: false })
is_online: boolean;
@Column({ type: 'numeric', precision: 3, scale: 2, default: 5 })
rating: number;
@Column({ type: 'double precision', nullable: true })
last_lat: number;
@Column({ type: 'double precision', nullable: true })
last_lng: number;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,27 @@
import { Controller, Get, Query, BadRequestException } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { MapsService } from './maps.service';
@ApiTags('maps')
@Controller('maps')
export class MapsController {
constructor(private readonly maps: MapsService) {}
// GET /maps/route?fromLat=&fromLng=&toLat=&toLng=
@Get('route')
route(
@Query('fromLat') fromLat: string,
@Query('fromLng') fromLng: string,
@Query('toLat') toLat: string,
@Query('toLng') toLng: string,
) {
const nums = [fromLat, fromLng, toLat, toLng].map(Number);
if (nums.some((n) => Number.isNaN(n))) {
throw new BadRequestException('fromLat, fromLng, toLat, toLng are required numbers');
}
return this.maps.route(
{ lat: nums[0], lng: nums[1] },
{ lat: nums[2], lng: nums[3] },
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MapsService } from './maps.service';
import { MapsController } from './maps.controller';
@Module({
controllers: [MapsController],
providers: [MapsService],
exports: [MapsService],
})
export class MapsModule {}
+49
View File
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
export interface LatLng {
lat: number;
lng: number;
}
export interface RouteResult {
distanceKm: number;
durationMin: number;
provider: string;
}
/**
* وكيل الخرائط. الهدف النهائي: توجيه/ترميز من خرائط انطلق الذاتية (docs/07).
* حالياً (P1) توجيه تقديري بخط الطول الجغرافي (haversine) + سرعة متوسطة —
* يفكّ ارتباط بقية النظام (التعرفة/الرحلات) عن تفاصيل انطلق حتى نربطها.
* الاستبدال لاحقاً = تنفيذ route() عبر واجهة انطلق دون تغيير من يستدعيها.
*/
@Injectable()
export class MapsService {
private readonly avgSpeedKmh = 30; // تقدير حضري
route(from: LatLng, to: LatLng): RouteResult {
const distanceKm = MapsService.haversineKm(from, to);
const durationMin = (distanceKm / this.avgSpeedKmh) * 60;
return {
distanceKm: Number(distanceKm.toFixed(3)),
durationMin: Number(durationMin.toFixed(1)),
provider: 'straight-line', // TODO: 'antlaq'
};
}
static haversineKm(a: LatLng, b: LatLng): number {
const R = 6371;
const dLat = MapsService.rad(b.lat - a.lat);
const dLng = MapsService.rad(b.lng - a.lng);
const lat1 = MapsService.rad(a.lat);
const lat2 = MapsService.rad(b.lat);
const h =
Math.sin(dLat / 2) ** 2 +
Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2);
return 2 * R * Math.asin(Math.sqrt(h));
}
private static rad(deg: number): number {
return (deg * Math.PI) / 180;
}
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MatchingService } from './matching.service';
@Module({
providers: [MatchingService],
exports: [MatchingService],
})
export class MatchingModule {}
@@ -0,0 +1,66 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS } from '../../common/redis/redis.module';
export interface NearbyDriver {
driverId: string;
distanceKm: number;
}
/**
* المطابقة الجغرافية عبر Redis GEO (docs/09). مفتاح لكل مستأجر:
* geo:drivers:{tenantId} (ببادئة tripz: تلقائياً من ioredis).
* نستخدم redis.call لتفادي تعقيد أنماط ioredis المتغيّرة.
*/
@Injectable()
export class MatchingService {
constructor(@Inject(REDIS) private readonly redis: Redis) {}
private key(tenantId: string): string {
return `geo:drivers:${tenantId}`;
}
async addDriver(tenantId: string, driverId: string, lat: number, lng: number) {
await this.redis.call(
'GEOADD',
this.key(tenantId),
String(lng),
String(lat),
driverId,
);
}
async removeDriver(tenantId: string, driverId: string) {
await this.redis.call('ZREM', this.key(tenantId), driverId);
}
/** أقرب السائقين ضمن نصف قطر (كم)، مرتبين بالأقرب. */
async findNearby(
tenantId: string,
lat: number,
lng: number,
radiusKm = 5,
count = 10,
): Promise<NearbyDriver[]> {
const res = (await this.redis.call(
'GEOSEARCH',
this.key(tenantId),
'FROMLONLAT',
String(lng),
String(lat),
'BYRADIUS',
String(radiusKm),
'km',
'ASC',
'COUNT',
String(count),
'WITHDIST',
)) as [string, string][];
if (!Array.isArray(res)) return [];
return res.map(([driverId, dist]) => ({
driverId,
distanceKm: Number(Number(dist).toFixed(3)),
}));
}
}
@@ -0,0 +1,61 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* تعريف تعرفة لكل (مستأجر × مدينة × فئة خدمة) كوثيقة JSON — راجع docs/04.
* الجدول: tripz_tariffs.
*/
@Entity('tariffs')
@Index(['tenant_id', 'city', 'service_class', 'active'])
export class Tariff {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column()
city: string;
@Column()
service_class: string;
// وثيقة التعرفة الكاملة (mode, windows, fees, rounding...) — راجع docs/04.
@Column({ type: 'jsonb' })
definition: TariffDefinition;
@Column({ type: 'int', default: 1 })
version: number;
@Column({ default: true })
active: boolean;
@CreateDateColumn()
created_at: Date;
}
export interface TariffWindow {
name: string;
from: string; // "HH:MM"
to: string;
flag: number;
per_km: number;
per_min: number;
per_min_waiting?: number;
}
export interface TariffDefinition {
currency: string;
rounding?: { increment: number; mode?: 'nearest' | 'up' | 'down' };
mode: 'time_and_distance' | 'time_or_distance' | 'fixed_quote';
speed_threshold_kmh?: number;
windows: TariffWindow[];
booking_fee?: number;
min_fare?: number;
surge?: { enabled: boolean; multiplier?: number; max?: number };
}
@@ -0,0 +1,34 @@
import { Body, Controller, Post, Headers, UnauthorizedException } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TariffService } from './tariff.service';
import { TenantsService } from '../tenants/tenants.service';
@ApiTags('tariff')
@Controller('tariff')
export class TariffController {
constructor(
private readonly tariff: TariffService,
private readonly tenants: TenantsService,
) {}
// POST /tariff/quote { city, serviceClass, distanceKm, durationMin }
@Post('quote')
async quote(
@Headers('x-tenant-id') tenantSlug: string,
@Body() body: any,
) {
if (!tenantSlug) throw new UnauthorizedException('x-tenant-id is required');
const tenant = await this.tenants.resolve(tenantSlug);
if (!tenant) throw new UnauthorizedException('Unknown tenant');
return this.tariff.quote(
tenant.id,
body.city,
body.serviceClass,
{
distanceKm: Number(body.distanceKm),
durationMin: Number(body.durationMin),
waitingMin: body.waitingMin ? Number(body.waitingMin) : 0,
},
);
}
}
+113
View File
@@ -0,0 +1,113 @@
import { TariffDefinition, TariffWindow } from './entities/tariff.entity';
export interface QuoteInput {
distanceKm: number;
durationMin: number;
waitingMin?: number;
at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية)
}
export interface QuoteBreakdown {
window: string;
flag: number;
distance: number;
time: number;
waiting: number;
bookingFee: number;
subtotal: number;
surgeMultiplier: number;
total: number;
currency: string;
}
/**
* محرك التعرفة النقي (قابل للاختبار بوحدات) — نفس المدخل = نفس المخرج.
* يدعم أوضاع docs/04: time_and_distance و time_or_distance (عتبة سرعة).
*/
export class TariffEngine {
static quote(def: TariffDefinition, input: QuoteInput): QuoteBreakdown {
const at = input.at ?? new Date();
const win = TariffEngine.pickWindow(def.windows, at);
const distanceKm = Math.max(0, input.distanceKm);
const durationMin = Math.max(0, input.durationMin);
const waitingMin = Math.max(0, input.waitingMin ?? 0);
let distanceCharge = 0;
let timeCharge = 0;
if (def.mode === 'time_or_distance') {
// العداد المنظَّم: تحت العتبة يُحسب بالدقيقة (زحمة)، فوقها بالكيلومتر.
const avgSpeed = durationMin > 0 ? (distanceKm / durationMin) * 60 : 999;
const threshold = def.speed_threshold_kmh ?? 18;
if (avgSpeed < threshold) {
timeCharge = durationMin * win.per_min;
} else {
distanceCharge = distanceKm * win.per_km;
}
} else {
// time_and_distance (و fixed_quote يُحسب مرة ويثبّت)
distanceCharge = distanceKm * win.per_km;
timeCharge = durationMin * win.per_min;
}
const waitingCharge = waitingMin * (win.per_min_waiting ?? 0);
const bookingFee = def.booking_fee ?? 0;
let subtotal =
win.flag + distanceCharge + timeCharge + waitingCharge + bookingFee;
const surgeMultiplier =
def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1;
let total = subtotal * surgeMultiplier;
if (def.min_fare && total < def.min_fare) total = def.min_fare;
total = TariffEngine.round(total, def.rounding);
return {
window: win.name,
flag: win.flag,
distance: TariffEngine.n(distanceCharge),
time: TariffEngine.n(timeCharge),
waiting: TariffEngine.n(waitingCharge),
bookingFee,
subtotal: TariffEngine.n(subtotal),
surgeMultiplier,
total: TariffEngine.n(total),
currency: def.currency,
};
}
private static pickWindow(windows: TariffWindow[], at: Date): TariffWindow {
const mins = at.getUTCHours() * 60 + at.getUTCMinutes();
for (const w of windows) {
const [fromH, fromM] = w.from.split(':').map(Number);
const [toH, toM] = w.to.split(':').map(Number);
const from = fromH * 60 + fromM;
const to = toH * 60 + toM;
// نافذة تعبر منتصف الليل (مثل 22:00 → 06:00)
if (from <= to ? mins >= from && mins < to : mins >= from || mins < to) {
return w;
}
}
return windows[0];
}
private static round(
value: number,
rounding?: { increment: number; mode?: 'nearest' | 'up' | 'down' },
): number {
if (!rounding || !rounding.increment) return TariffEngine.n(value);
const q = value / rounding.increment;
const r =
rounding.mode === 'up'
? Math.ceil(q)
: rounding.mode === 'down'
? Math.floor(q)
: Math.round(q);
return TariffEngine.n(r * rounding.increment);
}
private static n(v: number): number {
return Number(v.toFixed(3));
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tariff } from './entities/tariff.entity';
import { TariffService } from './tariff.service';
import { TariffController } from './tariff.controller';
import { TenantsModule } from '../tenants/tenants.module';
@Module({
imports: [TypeOrmModule.forFeature([Tariff]), TenantsModule],
controllers: [TariffController],
providers: [TariffService],
exports: [TariffService],
})
export class TariffModule {}
@@ -0,0 +1,49 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tariff } from './entities/tariff.entity';
import { TariffEngine, QuoteInput, QuoteBreakdown } from './tariff.engine';
@Injectable()
export class TariffService {
constructor(
@InjectRepository(Tariff)
private readonly repo: Repository<Tariff>,
) {}
getActive(
tenantId: string,
city: string,
serviceClass: string,
): Promise<Tariff | null> {
return this.repo.findOne({
where: {
tenant_id: tenantId,
city,
service_class: serviceClass,
active: true,
},
order: { version: 'DESC' },
});
}
create(data: Partial<Tariff>): Promise<Tariff> {
return this.repo.save(this.repo.create(data));
}
async quote(
tenantId: string,
city: string,
serviceClass: string,
input: QuoteInput,
): Promise<{ quote: QuoteBreakdown; tariffId: string; version: number }> {
const tariff = await this.getActive(tenantId, city, serviceClass);
if (!tariff) {
throw new NotFoundException(
`No active tariff for ${city}/${serviceClass}`,
);
}
const quote = TariffEngine.quote(tariff.definition, input);
return { quote, tariffId: tariff.id, version: tariff.version };
}
}
@@ -0,0 +1,38 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
/**
* سجل انتقالات الرحلة — تشخيص + مطلب المنظّم (docs/08). الجدول: tripz_trip_events.
*/
@Entity('trip_events')
@Index(['tenant_id', 'trip_id'])
export class TripEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
trip_id: string;
@Column({ nullable: true })
from_status: string;
@Column()
to_status: string;
@Column({ default: 'system' })
source: string; // rider | driver | system
@Column({ type: 'jsonb', default: {} })
payload: Record<string, any>;
@CreateDateColumn()
created_at: Date;
}
@@ -0,0 +1,96 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export type TripStatus =
| 'searching'
| 'assigned'
| 'driver_arriving'
| 'driver_arrived'
| 'in_progress'
| 'completed'
| 'paid'
| 'cancelled'
| 'no_drivers'
| 'expired';
/** الرحلة. الجدول: tripz_trips. آلة الحالة مصدرها الباك إند (docs/02). */
@Entity('trips')
@Index(['tenant_id', 'status'])
@Index(['tenant_id', 'rider_id'])
@Index(['tenant_id', 'driver_id'])
export class Trip {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
rider_id: string;
@Column({ type: 'uuid', nullable: true })
driver_id: string | null;
@Column({ default: 'economy' })
service_class: string;
@Column({ nullable: true })
city: string;
@Column({ type: 'double precision' })
origin_lat: number;
@Column({ type: 'double precision' })
origin_lng: number;
@Column({ type: 'double precision' })
dest_lat: number;
@Column({ type: 'double precision' })
dest_lng: number;
@Column({ type: 'varchar', default: 'searching' })
status: TripStatus;
@Column({ type: 'numeric', precision: 8, scale: 3, nullable: true })
distance_km: number | null;
@Column({ type: 'numeric', precision: 8, scale: 1, nullable: true })
duration_min: number | null;
@Column({ type: 'uuid', nullable: true })
tariff_id: string | null;
@Column({ type: 'int', nullable: true })
tariff_version: number | null;
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
quoted_fare: number | null;
@Column({ type: 'numeric', precision: 12, scale: 3, nullable: true })
final_fare: number | null;
@Column({ nullable: true })
currency: string;
@Column({ default: 'cash' })
payment_method: string;
@CreateDateColumn()
requested_at: Date;
@Column({ type: 'timestamptz', nullable: true })
assigned_at: Date | null;
@Column({ type: 'timestamptz', nullable: true })
completed_at: Date | null;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,53 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { TripsService, RequestTripDto } from './trips.service';
import { TripStatus } from './entities/trip.entity';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('trips')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('trips')
export class TripsController {
constructor(private readonly trips: TripsService) {}
// الراكب يطلب رحلة
@Post()
request(@CurrentUser() user: AuthUser, @Body() body: RequestTripDto) {
return this.trips.request(user.tenantId, user.userId, body);
}
@Get('mine')
mine(@CurrentUser() user: AuthUser) {
return this.trips.listForRider(user.tenantId, user.userId);
}
@Get(':id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.trips.get(user.tenantId, id);
}
// السائق يقبل الرحلة
@Post(':id/accept')
accept(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.trips.accept(user.tenantId, id, user.userId);
}
// تحديث الحالة (driver_arriving / driver_arrived / in_progress / completed / paid)
@Patch(':id/status')
status(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body('status') status: TripStatus,
) {
const actor = user.role === 'driver' ? 'driver' : 'rider';
return this.trips.updateStatus(user.tenantId, id, status, actor);
}
@Post(':id/cancel')
cancel(@CurrentUser() user: AuthUser, @Param('id') id: string) {
const actor = user.role === 'driver' ? 'driver' : 'rider';
return this.trips.cancel(user.tenantId, id, actor);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Trip } from './entities/trip.entity';
import { TripEvent } from './entities/trip-event.entity';
import { TripsService } from './trips.service';
import { TripsController } from './trips.controller';
import { MapsModule } from '../maps/maps.module';
import { TariffModule } from '../tariff/tariff.module';
import { MatchingModule } from '../matching/matching.module';
import { DriversModule } from '../drivers/drivers.module';
import { RealtimeModule } from '../../realtime/realtime.module';
@Module({
imports: [
TypeOrmModule.forFeature([Trip, TripEvent]),
MapsModule,
TariffModule,
MatchingModule,
DriversModule,
RealtimeModule,
],
controllers: [TripsController],
providers: [TripsService],
exports: [TripsService],
})
export class TripsModule {}
+233
View File
@@ -0,0 +1,233 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Trip, TripStatus } from './entities/trip.entity';
import { TripEvent } from './entities/trip-event.entity';
import { MapsService } from '../maps/maps.service';
import { TariffService } from '../tariff/tariff.service';
import { MatchingService } from '../matching/matching.service';
import { DriversService } from '../drivers/drivers.service';
import { RealtimeGateway } from '../../realtime/realtime.gateway';
/** الانتقالات المسموحة في آلة حالة الرحلة (docs/02). */
const TRANSITIONS: Record<TripStatus, TripStatus[]> = {
searching: ['assigned', 'cancelled', 'no_drivers', 'expired'],
assigned: ['driver_arriving', 'cancelled'],
driver_arriving: ['driver_arrived', 'cancelled'],
driver_arrived: ['in_progress', 'cancelled'],
in_progress: ['completed'],
completed: ['paid'],
paid: [],
cancelled: [],
no_drivers: [],
expired: [],
};
export interface RequestTripDto {
origin: { lat: number; lng: number };
destination: { lat: number; lng: number };
service_class?: string;
city?: string;
}
@Injectable()
export class TripsService {
private readonly logger = new Logger('Trips');
constructor(
@InjectRepository(Trip) private readonly trips: Repository<Trip>,
@InjectRepository(TripEvent) private readonly events: Repository<TripEvent>,
private readonly maps: MapsService,
private readonly tariff: TariffService,
private readonly matching: MatchingService,
private readonly drivers: DriversService,
private readonly gateway: RealtimeGateway,
) {}
get(tenantId: string, id: string): Promise<Trip | null> {
return this.trips.findOne({ where: { tenant_id: tenantId, id } });
}
listForRider(tenantId: string, riderId: string): Promise<Trip[]> {
return this.trips.find({
where: { tenant_id: tenantId, rider_id: riderId },
order: { requested_at: 'DESC' },
});
}
listForDriver(tenantId: string, driverId: string): Promise<Trip[]> {
return this.trips.find({
where: { tenant_id: tenantId, driver_id: driverId },
order: { requested_at: 'DESC' },
});
}
/** طلب رحلة: تسعير → إنشاء → مطابقة → بث عروض. */
async request(tenantId: string, riderId: string, dto: RequestTripDto) {
if (!dto?.origin || !dto?.destination) {
throw new BadRequestException('origin and destination are required');
}
const serviceClass = dto.service_class ?? 'economy';
const city = dto.city ?? 'default';
const route = this.maps.route(dto.origin, dto.destination);
// تسعير (اختياري — لو ما في تعرفة مفعّلة نكمل بلا سعر مقفول)
let quotedFare: number | null = null;
let currency: string | null = null;
let tariffId: string | null = null;
let tariffVersion: number | null = null;
try {
const q = await this.tariff.quote(tenantId, city, serviceClass, {
distanceKm: route.distanceKm,
durationMin: route.durationMin,
});
quotedFare = q.quote.total;
currency = q.quote.currency;
tariffId = q.tariffId;
tariffVersion = q.version;
} catch {
this.logger.warn(`no tariff for ${city}/${serviceClass} — trip without quote`);
}
let trip = this.trips.create({
tenant_id: tenantId,
rider_id: riderId,
service_class: serviceClass,
city,
origin_lat: dto.origin.lat,
origin_lng: dto.origin.lng,
dest_lat: dto.destination.lat,
dest_lng: dto.destination.lng,
status: 'searching',
distance_km: route.distanceKm,
duration_min: route.durationMin,
tariff_id: tariffId,
tariff_version: tariffVersion,
quoted_fare: quotedFare,
currency: currency ?? undefined,
});
trip = await this.trips.save(trip);
await this.recordEvent(trip, null, 'searching', 'rider');
// مطابقة وبث عروض للسائقين القريبين
const nearby = await this.matching.findNearby(
tenantId,
dto.origin.lat,
dto.origin.lng,
);
for (const n of nearby) {
const driver = await this.drivers.findById(tenantId, n.driverId);
if (driver && driver.verification_status === 'approved') {
this.gateway.offerToDriver(tenantId, driver.user_id, {
tripId: trip.id,
pickup: { lat: trip.origin_lat, lng: trip.origin_lng },
dropoff: { lat: trip.dest_lat, lng: trip.dest_lng },
distanceKm: n.distanceKm,
quotedFare,
currency,
});
}
}
this.gateway.dispatch(tenantId, 'trip:new', { tripId: trip.id });
return { trip, offeredDrivers: nearby.length };
}
/** قبول سائق للرحلة (أول قبول يفوز). */
async accept(tenantId: string, tripId: string, driverUserId: string) {
const trip = await this.getOr404(tenantId, tripId);
if (trip.status !== 'searching') {
throw new BadRequestException('Trip is no longer available');
}
const driver = await this.drivers.findByUser(tenantId, driverUserId);
if (!driver) throw new ForbiddenException('Not a driver');
if (driver.verification_status !== 'approved') {
throw new ForbiddenException('Driver not approved');
}
trip.driver_id = driver.id;
trip.assigned_at = new Date();
await this.applyTransition(trip, 'assigned', 'driver');
this.gateway.tripUpdate(tenantId, trip.rider_id, {
tripId: trip.id,
status: 'assigned',
driverId: driver.id,
});
return trip;
}
/** تحديث حالة الرحلة (سائق/راكب) عبر آلة الحالة. */
async updateStatus(
tenantId: string,
tripId: string,
toStatus: TripStatus,
actor: 'rider' | 'driver',
) {
const trip = await this.getOr404(tenantId, tripId);
if (toStatus === 'completed') trip.completed_at = new Date();
if (toStatus === 'completed' && trip.quoted_fare != null) {
trip.final_fare = trip.quoted_fare; // P1: النهائي = المقفول
}
await this.applyTransition(trip, toStatus, actor);
// بث لطرفَي الرحلة
this.gateway.tripUpdate(tenantId, trip.rider_id, { tripId: trip.id, status: toStatus });
if (trip.driver_id) {
const drv = await this.drivers.findById(tenantId, trip.driver_id);
if (drv) this.gateway.tripUpdate(tenantId, drv.user_id, { tripId: trip.id, status: toStatus });
}
return trip;
}
async cancel(tenantId: string, tripId: string, actor: 'rider' | 'driver') {
const trip = await this.getOr404(tenantId, tripId);
await this.applyTransition(trip, 'cancelled', actor);
this.gateway.tripUpdate(tenantId, trip.rider_id, { tripId: trip.id, status: 'cancelled' });
return trip;
}
// ---- داخلي ----
private async getOr404(tenantId: string, id: string): Promise<Trip> {
const trip = await this.get(tenantId, id);
if (!trip) throw new NotFoundException('Trip not found');
return trip;
}
private async applyTransition(trip: Trip, to: TripStatus, source: string) {
const allowed = TRANSITIONS[trip.status] ?? [];
if (!allowed.includes(to)) {
throw new BadRequestException(
`Invalid transition ${trip.status} -> ${to}`,
);
}
const from = trip.status;
trip.status = to;
await this.trips.save(trip);
await this.recordEvent(trip, from, to, source);
}
private async recordEvent(
trip: Trip,
from: string | null,
to: string,
source: string,
) {
await this.events.save(
this.events.create({
tenant_id: trip.tenant_id,
trip_id: trip.id,
from_status: from ?? undefined,
to_status: to,
source,
}),
);
}
}
+10 -1
View File
@@ -32,7 +32,16 @@ export class UsersService {
id: string,
data: Partial<Pick<User, 'name'>>,
): Promise<User | null> {
await this.userRepository.update({ tenant_id: tenantId, id }, { name: data.name });
if (data.name !== undefined) {
await this.userRepository.update({ tenant_id: tenantId, id }, { name: data.name });
}
return this.findById(tenantId, id);
}
async setRole(tenantId: string, id: string, role: string): Promise<void> {
await this.userRepository.update(
{ tenant_id: tenantId, id },
{ role: role as UserRole },
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Server, Socket } from 'socket.io';
/**
* البوابة الحية (Socket.IO) — docs/09. المصادقة على الاتصال بـ JWT،
* والانضمام لغرف مُنطَّقة بالمستأجر (لا تسريب بين المستأجرين).
* غرف: tenant:{id}:driver:{driverId} · tenant:{id}:trip:{tripId} · tenant:{id}:dispatch
*/
@WebSocketGateway({ cors: true })
export class RealtimeGateway implements OnGatewayConnection {
private readonly logger = new Logger('Realtime');
@WebSocketServer()
server: Server;
constructor(private readonly jwt: JwtService) {}
handleConnection(client: Socket) {
try {
const token =
(client.handshake.auth?.token as string) ||
(client.handshake.headers?.authorization as string)?.replace('Bearer ', '');
if (!token) throw new Error('no token');
const p: any = this.jwt.verify(token);
const tenantId = p.tenant_id;
client.data.user = { userId: p.sub, tenantId, role: p.role };
client.join(`tenant:${tenantId}:user:${p.sub}`);
if (p.role === 'driver') client.join(`tenant:${tenantId}:drivers`);
this.logger.debug(`connected user=${p.sub} tenant=${tenantId} role=${p.role}`);
} catch {
client.disconnect(true);
}
}
// ---- مساعدات البث التي تستدعيها الخدمات ----
/** عرض رحلة لسائق محدد. */
offerToDriver(tenantId: string, driverUserId: string, payload: any) {
this.server.to(`tenant:${tenantId}:user:${driverUserId}`).emit('trip:offer', payload);
}
/** تحديث حالة الرحلة للراكب أو السائق. */
tripUpdate(tenantId: string, userId: string, payload: any) {
this.server.to(`tenant:${tenantId}:user:${userId}`).emit('trip:update', payload);
}
/** بث للوحة المشغّل. */
dispatch(tenantId: string, event: string, payload: any) {
this.server.to(`tenant:${tenantId}:dispatch`).emit(event, payload);
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RealtimeGateway } from './realtime.gateway';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (cfg: ConfigService) => ({
secret: cfg.get<string>('JWT_SECRET') || 'change_me_jwt_secret',
}),
}),
],
providers: [RealtimeGateway],
exports: [RealtimeGateway],
})
export class RealtimeModule {}