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