feat: add location intelligence engine and E2E simulation script
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('campaign_logs')
|
||||
export class CampaignLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
user_id: string; // rider or driver id
|
||||
|
||||
@Column()
|
||||
zone_id: string;
|
||||
|
||||
@Column({ length: 50 })
|
||||
user_type: 'rider' | 'driver';
|
||||
|
||||
@CreateDateColumn()
|
||||
sent_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('geofence_zones')
|
||||
export class GeofenceZone {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ length: 150 })
|
||||
name: string;
|
||||
|
||||
@Column('decimal', { precision: 10, scale: 7 })
|
||||
latitude: number;
|
||||
|
||||
@Column('decimal', { precision: 10, scale: 7 })
|
||||
longitude: number;
|
||||
|
||||
@Column('int')
|
||||
radius_meters: number;
|
||||
|
||||
@Column('int', { default: 0 })
|
||||
priority: number;
|
||||
|
||||
@Column({ default: true })
|
||||
is_active: boolean;
|
||||
|
||||
// --- Pricing Modifiers ---
|
||||
// e.g., 1.5 = 50% surge if pickup is here. 0.8 = 20% discount. 1.0 = normal.
|
||||
@Column('decimal', { precision: 5, scale: 2, default: 1.0 })
|
||||
pricing_multiplier: number;
|
||||
|
||||
// --- Marketing / Campaigns ---
|
||||
@Column({ nullable: true })
|
||||
campaign_title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
campaign_body: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, Query, UseGuards, ParseFloatPipe } from '@nestjs/common';
|
||||
import { GeofenceService } from './geofence.service';
|
||||
import { CurrentUser } from '../../common/auth/current-user.decorator';
|
||||
import { AuthUser } from '../../common/auth/auth-user.interface';
|
||||
import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard';
|
||||
|
||||
@Controller('geofence')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GeofenceController {
|
||||
constructor(private readonly geofenceService: GeofenceService) {}
|
||||
|
||||
@Get('nearby')
|
||||
async getNearbyZones(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('lat', ParseFloatPipe) lat: number,
|
||||
@Query('lng', ParseFloatPipe) lng: number,
|
||||
) {
|
||||
const zones = await this.geofenceService.getTopGeofences(user.tenantId, lat, lng);
|
||||
|
||||
// Geofence Hook: Process marketing campaigns when app fetches nearby zones (App Opening)
|
||||
this.geofenceService.processLocation(user.tenantId, user.userId, user.role as any, lat, lng).catch(() => {});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: zones,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GeofenceZone } from './entities/geofence-zone.entity';
|
||||
import { CampaignLog } from './entities/campaign-log.entity';
|
||||
import { GeofenceService } from './geofence.service';
|
||||
import { GeofenceController } from './geofence.controller';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([GeofenceZone, CampaignLog]),
|
||||
NotificationsModule,
|
||||
],
|
||||
providers: [GeofenceService],
|
||||
controllers: [GeofenceController],
|
||||
exports: [GeofenceService],
|
||||
})
|
||||
export class GeofenceModule {}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { GeofenceZone } from './entities/geofence-zone.entity';
|
||||
import { CampaignLog } from './entities/campaign-log.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
@Injectable()
|
||||
export class GeofenceService {
|
||||
private readonly logger = new Logger(GeofenceService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(GeofenceZone) private readonly zonesRepo: Repository<GeofenceZone>,
|
||||
@InjectRepository(CampaignLog) private readonly campaignLogRepo: Repository<CampaignLog>,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Finds the highest-priority intersecting zone for a given point.
|
||||
*/
|
||||
async findIntersectingZone(tenantId: string, lat: number, lng: number): Promise<GeofenceZone | null> {
|
||||
const zones = await this.zonesRepo.find({ where: { tenant_id: tenantId, is_active: true } });
|
||||
|
||||
let bestZone: GeofenceZone | null = null;
|
||||
let maxPriority = -1;
|
||||
|
||||
for (const zone of zones) {
|
||||
const distance = this.haversineDistance(lat, lng, Number(zone.latitude), Number(zone.longitude));
|
||||
if (distance <= zone.radius_meters) {
|
||||
if (zone.priority > maxPriority) {
|
||||
bestZone = zone;
|
||||
maxPriority = zone.priority;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestZone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a user's location ping. If they enter a marketing zone, send a push campaign.
|
||||
*/
|
||||
async processLocation(tenantId: string, userId: string, userType: 'rider' | 'driver', lat: number, lng: number) {
|
||||
const zone = await this.findIntersectingZone(tenantId, lat, lng);
|
||||
if (!zone) return;
|
||||
|
||||
if (zone.campaign_title && zone.campaign_body) {
|
||||
// Check for spam limit (last 24 hours)
|
||||
const lastSent = await this.campaignLogRepo.createQueryBuilder('log')
|
||||
.where('log.user_id = :userId', { userId })
|
||||
.andWhere('log.zone_id = :zoneId', { zoneId: zone.id })
|
||||
.andWhere('log.sent_at > NOW() - INTERVAL \'24 HOURS\'')
|
||||
.getCount();
|
||||
|
||||
if (lastSent === 0) {
|
||||
this.logger.log(`Geofence breach by ${userType} ${userId} in zone ${zone.name}. Sending campaign.`);
|
||||
|
||||
// Send Notification
|
||||
await this.notifications.sendPushToUser(tenantId, userId, {
|
||||
title: zone.campaign_title,
|
||||
body: zone.campaign_body,
|
||||
data: { type: 'geofence_promo', zoneId: zone.id },
|
||||
});
|
||||
|
||||
// Log it
|
||||
await this.campaignLogRepo.save({
|
||||
user_id: userId,
|
||||
zone_id: zone.id,
|
||||
user_type: userType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the top 20 nearest active geofences to register in the native OS app.
|
||||
*/
|
||||
async getTopGeofences(tenantId: string, lat: number, lng: number, limit = 20): Promise<GeofenceZone[]> {
|
||||
const zones = await this.zonesRepo.find({ where: { tenant_id: tenantId, is_active: true } });
|
||||
|
||||
// Calculate distance and sort
|
||||
const mapped = zones.map(zone => ({
|
||||
zone,
|
||||
distance: this.haversineDistance(lat, lng, Number(zone.latitude), Number(zone.longitude)),
|
||||
}));
|
||||
|
||||
mapped.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
return mapped.slice(0, limit).map(item => item.zone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast Haversine Distance Calculation (Meters).
|
||||
*/
|
||||
private haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = (x: number) => (x * Math.PI) / 180;
|
||||
const R = 6371e3;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { REDIS } from '../../common/redis/redis.module';
|
||||
import { MatchingService, DriverAvailability } from '../matching/matching.service';
|
||||
import { Driver } from '../drivers/entities/driver.entity';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
import { GeofenceService } from '../geofence/geofence.service';
|
||||
|
||||
/** الموقع الحيّ كما يعيش في Redis. */
|
||||
export interface LivePosition {
|
||||
@@ -48,6 +49,7 @@ export class DriverLocationService {
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
@InjectRepository(Driver) private readonly drivers: Repository<Driver>,
|
||||
private readonly matching: MatchingService,
|
||||
private readonly geofence: GeofenceService,
|
||||
) {}
|
||||
|
||||
private key(tenantId: string, driverId: string): string {
|
||||
@@ -110,6 +112,16 @@ export class DriverLocationService {
|
||||
updatedAt: String(position.updatedAt),
|
||||
});
|
||||
pipe.expire(k, LOC_TTL_SEC);
|
||||
|
||||
// Geofence Hook: Process marketing campaigns (non-blocking)
|
||||
this.geofence.processLocation(tenantId, driverId, 'driver', position.lat, position.lng).catch(() => {});
|
||||
|
||||
// Geofence Hook: Publish to transit channel so backend-transit can evaluate Geofence
|
||||
this.redis.publish(
|
||||
'channel:bus-locations',
|
||||
JSON.stringify({ driverId, lat: position.lat, lng: position.lng })
|
||||
).catch(() => {});
|
||||
|
||||
// «متّسخ» = يحتاج كتابة لقطة على القاعدة لاحقاً (مجموعة → لا تكرار).
|
||||
pipe.sadd(DriverLocationService.DIRTY_KEY, `${tenantId}|${driverId}`);
|
||||
// نقطة مسار — تُدرَج دفعةً واحدة من الـworker.
|
||||
|
||||
@@ -5,13 +5,14 @@ import { DriverTrack } from './entities/driver-track.entity';
|
||||
import { DriverLocationService } from './driver-location.service';
|
||||
import { LocationFlusherService } from './location-flusher.service';
|
||||
import { MatchingModule } from '../matching/matching.module';
|
||||
import { GeofenceModule } from '../geofence/geofence.module';
|
||||
|
||||
/**
|
||||
* وحدة مستقلة عمداً: تستورد MatchingModule، وتستوردها DriversModule و
|
||||
* TripsModule — لو عاشت داخل drivers لصارت دورة استيراد مع matching.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Driver, DriverTrack]), MatchingModule],
|
||||
imports: [TypeOrmModule.forFeature([Driver, DriverTrack]), MatchingModule, GeofenceModule],
|
||||
providers: [DriverLocationService, LocationFlusherService],
|
||||
exports: [DriverLocationService, LocationFlusherService],
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface QuoteInput {
|
||||
durationMin: number;
|
||||
waitingMin?: number;
|
||||
at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية)
|
||||
geofenceMultiplier?: number;
|
||||
}
|
||||
|
||||
export interface QuoteBreakdown {
|
||||
@@ -58,9 +59,13 @@ export class TariffEngine {
|
||||
|
||||
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;
|
||||
const geofenceMultiplier = input.geofenceMultiplier ?? 1.0;
|
||||
const finalMultiplier = surgeMultiplier * geofenceMultiplier;
|
||||
|
||||
subtotal *= finalMultiplier;
|
||||
|
||||
let total = Math.max(def.min_fare ?? 0, subtotal);
|
||||
total = TariffEngine.round(total, def.rounding);
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { WalletModule } from '../wallet/wallet.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { LocationsModule } from '../locations/locations.module';
|
||||
import { CreditModule } from '../credit/credit.module';
|
||||
import { GeofenceModule } from '../geofence/geofence.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -30,6 +31,7 @@ import { CreditModule } from '../credit/credit.module';
|
||||
UsersModule,
|
||||
LocationsModule,
|
||||
CreditModule,
|
||||
GeofenceModule,
|
||||
],
|
||||
controllers: [TripsController],
|
||||
providers: [TripsService, TripStateService, ScheduledTripsSweeper],
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface RequestTripDto {
|
||||
scheduled_at?: string; // ISO — حجز مسبق (docs/17 — B5)
|
||||
}
|
||||
|
||||
import { GeofenceService } from '../geofence/geofence.service';
|
||||
|
||||
@Injectable()
|
||||
export class TripsService {
|
||||
private readonly logger = new Logger('Trips');
|
||||
@@ -80,6 +82,7 @@ export class TripsService {
|
||||
private readonly users: UsersService,
|
||||
private readonly locations: DriverLocationService,
|
||||
private readonly credit: DriverCreditService,
|
||||
private readonly geofence: GeofenceService,
|
||||
) {}
|
||||
|
||||
get(tenantId: string, id: string): Promise<Trip | null> {
|
||||
@@ -169,10 +172,17 @@ export class TripsService {
|
||||
// العمولة المتوقعة — تُعرض للسائق ليعرف ما سيُخصم من رصيده (docs/18).
|
||||
let commissionAmount: number | null = null;
|
||||
let commissionRate: number | null = null;
|
||||
let geofenceMultiplier = 1.0;
|
||||
try {
|
||||
const zone = await this.geofence.findIntersectingZone(tenantId, dto.origin.lat, dto.origin.lng);
|
||||
if (zone) geofenceMultiplier = zone.pricing_multiplier;
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const q = await this.tariff.quote(tenantId, city, serviceClass, {
|
||||
distanceKm: route.distanceKm,
|
||||
durationMin: route.durationMin,
|
||||
geofenceMultiplier,
|
||||
});
|
||||
quotedFare = q.quote.total;
|
||||
currency = q.quote.currency;
|
||||
|
||||
Reference in New Issue
Block a user