diff --git a/backend/e2e-tester/package-lock.json b/backend/e2e-tester/package-lock.json new file mode 100644 index 0000000..18832bd --- /dev/null +++ b/backend/e2e-tester/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": "tripz-e2e-tester", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tripz-e2e-tester", + "version": "1.0.0", + "dependencies": { + "socket.io-client": "^4.7.5" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + } + } +} diff --git a/backend/e2e-tester/package.json b/backend/e2e-tester/package.json new file mode 100644 index 0000000..90a505d --- /dev/null +++ b/backend/e2e-tester/package.json @@ -0,0 +1,8 @@ +{ + "name": "tripz-e2e-tester", + "version": "1.0.0", + "type": "module", + "dependencies": { + "socket.io-client": "^4.7.5" + } +} diff --git a/backend/e2e-tester/simulation.js b/backend/e2e-tester/simulation.js new file mode 100644 index 0000000..963bed0 --- /dev/null +++ b/backend/e2e-tester/simulation.js @@ -0,0 +1,163 @@ +import readline from 'readline'; +import { io } from 'socket.io-client'; + +const API_URL = process.env.API_URL || 'http://localhost:4010'; +const TENANT_ID = 'tripz'; // Change this if you have a different slug + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +const ask = (query) => new Promise((resolve) => rl.question(query, resolve)); + +// --- API Helpers --- +async function api(method, path, body = null, token = null) { + const headers = { + 'Content-Type': 'application/json', + 'x-tenant-id': TENANT_ID, + 'x-device-id': 'simulated-device-123' + }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + const options = { method, headers }; + if (body) options.body = JSON.stringify(body); + + const res = await fetch(`${API_URL}${path}`, options); + const data = await res.json().catch(() => null); + + if (!res.ok) { + console.error(`\x1b[31m[API Error] ${method} ${path} -> ${res.status}\x1b[0m`, data); + throw new Error(`API Error: ${res.status}`); + } + return data; +} + +// --- Main Simulation --- +async function run() { + console.log('\n\x1b[36m=========================================='); + console.log('🚀 Tripz Backend Live Simulation Engine'); + console.log('==========================================\x1b[0m\n'); + + // 1. Authentication + const driverPhone = await ask('📞 Enter Driver Phone Number (e.g. +962791234567): '); + console.log(`[Driver] Requesting OTP for ${driverPhone}...`); + await api('POST', '/auth/send-otp', { phone: driverPhone }); + + const driverOtp = await ask(`🔑 Enter OTP received by Driver ${driverPhone}: `); + const driverAuth = await api('POST', '/auth/verify-otp', { phone: driverPhone, code: driverOtp }); + const driverToken = driverAuth.access_token; + console.log('\x1b[32m[Driver] Authenticated successfully!\x1b[0m'); + + const riderPhone = await ask('\n📞 Enter Rider Phone Number (e.g. +962781234567): '); + console.log(`[Rider] Requesting OTP for ${riderPhone}...`); + await api('POST', '/auth/send-otp', { phone: riderPhone }); + + const riderOtp = await ask(`🔑 Enter OTP received by Rider ${riderPhone}: `); + const riderAuth = await api('POST', '/auth/verify-otp', { phone: riderPhone, code: riderOtp }); + const riderToken = riderAuth.access_token; + console.log('\x1b[32m[Rider] Authenticated successfully!\x1b[0m\n'); + + // 2. Setup Driver Profile & Go Online + console.log(`[Driver] Creating Driver profile...`); + await api('POST', '/drivers/apply', { service_class: 'economy', vehicle_make: 'Toyota', vehicle_model: 'Prius', vehicle_plate: '12-3456' }, driverToken); + + console.log(`[Driver] Going Online...`); + await api('PATCH', '/drivers/status', { online: true }, driverToken); + console.log('\x1b[32m[Driver] Profile created and is now ONLINE.\x1b[0m\n'); + + // 3. Connect WebSockets + console.log(`[Sockets] Connecting Rider and Driver via WebSockets...`); + const driverSocket = io(API_URL, { extraHeaders: { Authorization: `Bearer ${driverToken}` } }); + const riderSocket = io(API_URL, { extraHeaders: { Authorization: `Bearer ${riderToken}` } }); + + let tripId = null; + + driverSocket.on('connect', () => console.log('\x1b[36m[Driver WS] Connected to Gateway\x1b[0m')); + riderSocket.on('connect', () => console.log('\x1b[36m[Rider WS] Connected to Gateway\x1b[0m')); + + driverSocket.on('trip:dispatch', async (data) => { + console.log(`\n\x1b[35m[Driver WS] 🔔 RECEIVED DISPATCH for Trip ${data.id}!\x1b[0m`); + tripId = data.id; + + console.log(`[Driver] Accepting Trip ${tripId}...`); + await api('POST', `/trips/${tripId}/accept`, null, driverToken); + console.log('\x1b[32m[Driver] Trip Accepted!\x1b[0m'); + + // Join Trip Room + driverSocket.emit('trip:join', { tripId }); + riderSocket.emit('trip:join', { tripId }); + console.log(`[Sockets] Both joined trip room: ${tripId}\n`); + }); + + riderSocket.on('trip:status_changed', (data) => { + console.log(`\x1b[33m[Rider WS] Trip Status Changed -> ${data.status}\x1b[0m`); + }); + + // 4. Driver Location Pings + let driverLat = 32.0645; // Zarqa Coordinates + let driverLng = 36.0827; + + const locationInterval = setInterval(() => { + // Driver moves slightly + driverLat += 0.0001; + driverSocket.emit('driver:location', { lat: driverLat, lng: driverLng, speed: 40, heading: 90 }); + console.log(`[Driver WS] 📍 Location ping: ${driverLat.toFixed(5)}, ${driverLng.toFixed(5)}`); + }, 3000); + + // Wait a few seconds before Rider requests + await new Promise(r => setTimeout(r, 5000)); + + // 5. Rider Requests Trip + console.log(`\n[Rider] Requesting Trip from Zarqa...`); + const tripRequest = await api('POST', '/trips', { + city: 'amman', + service_class: 'economy', + origin: { lat: 32.0650, lng: 36.0830 }, // Zarqa origin + destination: { lat: 32.0700, lng: 36.0900 } + }, riderToken); + + console.log(`\x1b[32m[Rider] Trip created: ${tripRequest.id}\x1b[0m`); + + // 6. Simulate Trip Flow (Wait 10 seconds per state) + await new Promise(r => setTimeout(r, 10000)); + + if (tripId) { + console.log(`\n[Driver] Arriving at pickup...`); + await api('PATCH', `/trips/${tripId}/status`, { status: 'driver_arrived' }, driverToken); + + await new Promise(r => setTimeout(r, 5000)); + + console.log(`\n[Driver] Starting Trip...`); + await api('PATCH', `/trips/${tripId}/status`, { status: 'in_progress' }, driverToken); + + console.log(`\n[System] Simulating 1-minute ride to destination...`); + // Simulate 30 seconds wait + await new Promise(r => setTimeout(r, 30000)); + console.log(`[System] Halfway there...`); + await new Promise(r => setTimeout(r, 30000)); + + console.log(`\n[Driver] Completing Trip...`); + await api('PATCH', `/trips/${tripId}/status`, { status: 'completed' }, driverToken); + + await new Promise(r => setTimeout(r, 2000)); + + console.log(`\n[Driver] Collecting Cash...`); + await api('PATCH', `/trips/${tripId}/status`, { status: 'paid' }, driverToken); + + console.log('\n\x1b[32m🎉 Simulation Completed Successfully! 🎉\x1b[0m\n'); + } else { + console.log('\n\x1b[31mFailed to receive dispatch!\x1b[0m'); + } + + // Cleanup + clearInterval(locationInterval); + driverSocket.disconnect(); + riderSocket.disconnect(); + rl.close(); +} + +run().catch(err => { + console.error('\x1b[31mSimulation Failed!\x1b[0m', err); + process.exit(1); +}); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7ae000f..6b6e40c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -43,6 +43,7 @@ import { DocumentsModule } from './modules/documents/documents.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { GeminiModule } from './integrations/gemini/gemini.module'; import { BillingModule } from './modules/billing/billing.module'; +import { GeofenceModule } from './modules/geofence/geofence.module'; @Module({ imports: [ @@ -82,6 +83,7 @@ import { BillingModule } from './modules/billing/billing.module'; NotificationsModule, // عالمي — FCM StorageModule, // عالمي — تخزين ملفات الوثائق GeminiModule, // عالمي — رؤية AI (وثائق + وجه) + GeofenceModule, HealthModule, TenantsModule, diff --git a/backend/src/modules/geofence/entities/campaign-log.entity.ts b/backend/src/modules/geofence/entities/campaign-log.entity.ts new file mode 100644 index 0000000..53dbd0a --- /dev/null +++ b/backend/src/modules/geofence/entities/campaign-log.entity.ts @@ -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; +} diff --git a/backend/src/modules/geofence/entities/geofence-zone.entity.ts b/backend/src/modules/geofence/entities/geofence-zone.entity.ts new file mode 100644 index 0000000..f6cd88e --- /dev/null +++ b/backend/src/modules/geofence/entities/geofence-zone.entity.ts @@ -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; +} diff --git a/backend/src/modules/geofence/geofence.controller.ts b/backend/src/modules/geofence/geofence.controller.ts new file mode 100644 index 0000000..050c301 --- /dev/null +++ b/backend/src/modules/geofence/geofence.controller.ts @@ -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, + }; + } +} diff --git a/backend/src/modules/geofence/geofence.module.ts b/backend/src/modules/geofence/geofence.module.ts new file mode 100644 index 0000000..9878f94 --- /dev/null +++ b/backend/src/modules/geofence/geofence.module.ts @@ -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 {} diff --git a/backend/src/modules/geofence/geofence.service.ts b/backend/src/modules/geofence/geofence.service.ts new file mode 100644 index 0000000..78911c8 --- /dev/null +++ b/backend/src/modules/geofence/geofence.service.ts @@ -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, + @InjectRepository(CampaignLog) private readonly campaignLogRepo: Repository, + private readonly notifications: NotificationsService, + ) {} + + /** + * Finds the highest-priority intersecting zone for a given point. + */ + async findIntersectingZone(tenantId: string, lat: number, lng: number): Promise { + 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 { + 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; + } +} diff --git a/backend/src/modules/locations/driver-location.service.ts b/backend/src/modules/locations/driver-location.service.ts index d86e84a..61e736c 100644 --- a/backend/src/modules/locations/driver-location.service.ts +++ b/backend/src/modules/locations/driver-location.service.ts @@ -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, 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. diff --git a/backend/src/modules/locations/locations.module.ts b/backend/src/modules/locations/locations.module.ts index f697125..dffc171 100644 --- a/backend/src/modules/locations/locations.module.ts +++ b/backend/src/modules/locations/locations.module.ts @@ -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], }) diff --git a/backend/src/modules/tariff/tariff.engine.ts b/backend/src/modules/tariff/tariff.engine.ts index 2833d03..336e97e 100644 --- a/backend/src/modules/tariff/tariff.engine.ts +++ b/backend/src/modules/tariff/tariff.engine.ts @@ -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 { diff --git a/backend/src/modules/trips/trips.module.ts b/backend/src/modules/trips/trips.module.ts index 679d527..c1c25a8 100644 --- a/backend/src/modules/trips/trips.module.ts +++ b/backend/src/modules/trips/trips.module.ts @@ -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], diff --git a/backend/src/modules/trips/trips.service.ts b/backend/src/modules/trips/trips.service.ts index 516e35d..ccdf915 100644 --- a/backend/src/modules/trips/trips.service.ts +++ b/backend/src/modules/trips/trips.service.ts @@ -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 { @@ -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;