Update backend to use firebase-admin SDK for FCM
This commit is contained in:
+33
-4
@@ -58,10 +58,8 @@ SMS_USERNAME=
|
||||
SMS_PASSWORD_EGYPT=
|
||||
SMS_SENDER=
|
||||
|
||||
# ---- FCM push (اتركه فارغاً لتعطيل الإرسال) ----
|
||||
FCM_SERVER_KEY=
|
||||
FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send
|
||||
|
||||
# ---- FCM push (ملف الـ JSON الخاص بـ Service Account) ----
|
||||
GOOGLE_APPLICATION_CREDENTIALS=./firebase-service-account.json
|
||||
# ---- Gemini (رؤية: قراءة وثائق + مطابقة وجه + تحليل رسائل التحويل P2) ----
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-2.0-flash
|
||||
@@ -80,5 +78,36 @@ PAYMOB_INTEGRATION_ID_DRIVER_CARD=
|
||||
PAYMOB_INTEGRATION_ID_DRIVER_WALLET=
|
||||
PAYMOB_IFRAME_ID=
|
||||
|
||||
# ---- CliQ (الأردن) — بوابة الدفع الإلكتروني ----
|
||||
# الإنتاج: كل مستأجر مفاتيحه الخاصة في tenant.settings.payments.cliq
|
||||
CLIQ_BASE_URL=https://api.cliq.jo
|
||||
CLIQ_MERCHANT_ID=
|
||||
CLIQ_API_KEY=
|
||||
CLIQ_SECRET_KEY=
|
||||
CLIQ_TERMINAL_ID=
|
||||
|
||||
# ---- SyriaTel (سوريا) — بوابة الدفع الإلكتروني ----
|
||||
SYRIATEL_BASE_URL=https://e-payment.syriatel.com/api
|
||||
SYRIATEL_MERCHANT_CODE=
|
||||
SYRIATEL_API_KEY=
|
||||
SYRIATEL_SECRET_KEY=
|
||||
SYRIATEL_SERVICE_ID=
|
||||
|
||||
# ---- خدمة المواصلات (microservice مستقل) ----
|
||||
TRANSIT_SERVICE_URL=http://transit:4020
|
||||
|
||||
# ---- MTN (Mobile Money) — بوابة الدفع الإلكتروني ----
|
||||
MTN_BASE_URL=https://proxy.momoapi.mtn.com
|
||||
MTN_API_KEY=
|
||||
MTN_API_USER=
|
||||
MTN_SUBSCRIPTION_KEY=
|
||||
MTN_ENVIRONMENT=sandbox
|
||||
|
||||
# ---- L4: التسعير الديناميكي (Dynamic Pricing) ----
|
||||
# ملاحظة: فاصل كرون SURGE_RECALCULATE ثابت بالكود (180 ثانية) في cron-registry.ts
|
||||
# TRIP_SURGE_COOLDOWN_MS و TRIP_SURGE_SENSITIVITY يُقرأان فعلاً من SurgeService
|
||||
TRIP_SURGE_COOLDOWN_MS=300000 # 5 دقائق بين تغييرين متتاليين
|
||||
TRIP_SURGE_SENSITIVITY=0.3 # 0.1 خفيفة، 0.3 متوسطة، 0.5 حادة
|
||||
|
||||
# ---- سرّ المنصة (لتعيين أول أدمن) — ولّده: openssl rand -hex 24 ----
|
||||
PLATFORM_SECRET=
|
||||
|
||||
@@ -57,6 +57,7 @@ services:
|
||||
# اللوحات الثلاث — bind للقراءة فقط، لا نسخ داخل الصورة: تعديل HTML
|
||||
# يظهر بتحديث الصفحة بلا إعادة بناء (اللوحات ملفات ساكنة بلا خطوة بناء).
|
||||
- ../dashboards:/app/dashboards:ro
|
||||
- ./firebase-service-account.json:/app/firebase-service-account.json:ro
|
||||
networks: [tripz-net]
|
||||
|
||||
worker:
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.7.5",
|
||||
"typeorm": "^0.3.20"
|
||||
"typeorm": "^0.3.20",
|
||||
"firebase-admin": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
|
||||
@@ -45,6 +45,11 @@ 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';
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { CronModule } from './common/cron/cron.module';
|
||||
import { MarketingModule } from './modules/marketing/marketing.module';
|
||||
import { TransitModule } from './modules/transit/transit.module';
|
||||
import { BotModule } from './modules/bots/bot.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -111,6 +116,11 @@ import { GeofenceModule } from './modules/geofence/geofence.module';
|
||||
DocumentsModule,
|
||||
VehiclesModule,
|
||||
SeedModule,
|
||||
CatalogModule,
|
||||
CronModule, // O5 — المهام الدورية الموحّدة على BullMQ
|
||||
MarketingModule, // O3 — محرك التسويق التلقائي
|
||||
TransitModule, // O2 — نظام المواصلات
|
||||
BotModule, // O4 — بوتات التواصل الاجتماعي
|
||||
],
|
||||
providers: [
|
||||
// ThrottlerModule.forRoot() وحده لا يفعل شيئاً — كان مسجَّلاً منذ البداية
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
/**
|
||||
* اسم الطابور الوحيد — كل المهام الدورية تدخل هنا.
|
||||
* البادئة `tripz_` تأتي من `QUEUE_PREFIX` في الإعدادات.
|
||||
*/
|
||||
export const CRON_QUEUE = 'tripz_cron';
|
||||
|
||||
/**
|
||||
* وحدة BullMQ عالمية — تنشئ طابوراً واحداً لكل المهام الدورية.
|
||||
*
|
||||
* النمط: `@Global` مثل `RedisModule` — تُصدَّر وتُستهلك في أي وحدة.
|
||||
* الطابور يقرأ اتصال Redis من `REDIS_CLIENT` (نفس DB 3 + البادئة tripz:).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: CRON_QUEUE,
|
||||
inject: [ConfigService],
|
||||
useFactory: (cfg: ConfigService) =>
|
||||
new Queue(CRON_QUEUE, {
|
||||
connection: {
|
||||
host: cfg.get<string>('redis.host'),
|
||||
port: cfg.get<number>('redis.port'),
|
||||
db: cfg.get<number>('redis.db'),
|
||||
},
|
||||
prefix: cfg.get<string>('queue.prefix'),
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
},
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [CRON_QUEUE],
|
||||
})
|
||||
export class BullModule {}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlatformGuard } from '../platform/platform.guard';
|
||||
import { JobExecution } from './job-execution.entity';
|
||||
import { CronOrchestratorService } from './cron-orchestrator.service';
|
||||
import { CRON_SCHEDULES, CRON_JOB_LABELS, CronJobName } from './cron-registry';
|
||||
|
||||
/**
|
||||
* لوحة المهام الدورية — السوبر-أدن (O5).
|
||||
*
|
||||
* تُظهر: آخر نجاح/فشل لكل مهمة + مدة التنفيذ + السجل التاريخي.
|
||||
* لا مهمة صامتة — كل ما في الجدول هو ما حدث فعلاً.
|
||||
*/
|
||||
@ApiTags('cron')
|
||||
@Controller()
|
||||
export class CronAdminController {
|
||||
constructor(
|
||||
private readonly orchestrator: CronOrchestratorService,
|
||||
@InjectRepository(JobExecution)
|
||||
private readonly execRepo: Repository<JobExecution>,
|
||||
) {}
|
||||
|
||||
/** إحصائيات كل المهام + آخر تنفيذ. */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/cron/stats')
|
||||
stats() {
|
||||
return this.orchestrator.getStats();
|
||||
}
|
||||
|
||||
/** سجل التنفيذ التاريخي (مع التصفية حسب اسم المهمة). */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/cron/executions')
|
||||
async listExecutions(
|
||||
@Query('job_name') jobName?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
const take = Math.min(parseInt(limit ?? '50', 10) || 50, 200);
|
||||
const where: any = {};
|
||||
if (jobName) where.job_name = jobName;
|
||||
return this.execRepo.find({ where, order: { started_at: 'DESC' }, take });
|
||||
}
|
||||
|
||||
/** آخر 10 تنفيذات لكل مهمة (ملخص سريع). */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/cron/summary')
|
||||
async summary() {
|
||||
const jobs = Object.keys(CRON_SCHEDULES) as CronJobName[];
|
||||
const result: Record<string, {
|
||||
label: string;
|
||||
everyMs: number;
|
||||
recent: Array<{ status: string; started_at: Date; duration_ms: number | null; items_processed: number; error_message: string | null }>;
|
||||
}> = {};
|
||||
|
||||
for (const name of jobs) {
|
||||
const recent = await this.execRepo.find({
|
||||
where: { job_name: name },
|
||||
order: { started_at: 'DESC' },
|
||||
take: 10,
|
||||
});
|
||||
result[name] = {
|
||||
label: CRON_JOB_LABELS[name],
|
||||
everyMs: CRON_SCHEDULES[name].everyMs,
|
||||
recent: recent.map((e) => ({
|
||||
status: e.status,
|
||||
started_at: e.started_at,
|
||||
duration_ms: e.duration_ms,
|
||||
items_processed: e.items_processed,
|
||||
error_message: e.error_message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Queue } from 'bullmq';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CRON_SCHEDULES, CRON_JOB_LABELS, CronJobName } from './cron-registry';
|
||||
import { CRON_QUEUE } from './bull.module';
|
||||
import { JobExecution } from './job-execution.entity';
|
||||
|
||||
/**
|
||||
* منسّق المهام الدورية (O5).
|
||||
*
|
||||
* يعمل في عملية **الـAPI** فقط (ليس الـworker) لأنه يملك حقن كل الخدمات.
|
||||
* مسؤولياته:
|
||||
* 1. تسجيل المهام المتكررة (repeatable jobs) عند بدء التشغيل
|
||||
* 2. قفل ضد التشغيل المزدوج (BullMQ concurrency = 1)
|
||||
* 3. تعطيل المهام عند الإغلاق
|
||||
*
|
||||
* لا تُسجَّل المهام هنا — `CronWorker` هو من يprocessها ويكتب السجل.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CronOrchestratorService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger('CronOrchestrator');
|
||||
private readonly registeredJobs = new Map<CronJobName, string>();
|
||||
|
||||
constructor(
|
||||
@InjectRepository(JobExecution)
|
||||
private readonly execRepo: Repository<JobExecution>,
|
||||
@Inject(CRON_QUEUE)
|
||||
private readonly queue: Queue,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const runningJobs = await this.execRepo.find({
|
||||
where: { status: 'running' },
|
||||
order: { started_at: 'DESC' },
|
||||
});
|
||||
|
||||
if (runningJobs.length > 0) {
|
||||
this.logger.warn(
|
||||
`found ${runningJobs.length} orphaned running job(s) — marking as timeout`,
|
||||
);
|
||||
for (const job of runningJobs) {
|
||||
job.status = 'timeout';
|
||||
job.finished_at = new Date();
|
||||
job.error_message = 'Orphaned: server restarted while job was running';
|
||||
await this.execRepo.save(job);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, schedule] of Object.entries(CRON_SCHEDULES) as [CronJobName, (typeof CRON_SCHEDULES)[CronJobName]][]) {
|
||||
await this.queue.upsertJobScheduler(
|
||||
name,
|
||||
{ every: schedule.everyMs },
|
||||
{
|
||||
name,
|
||||
data: { name },
|
||||
opts: {
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
},
|
||||
},
|
||||
);
|
||||
this.registeredJobs.set(name, name);
|
||||
this.logger.log(`registered: ${name} (every ${schedule.everyMs / 1000}s)`);
|
||||
}
|
||||
|
||||
this.logger.log(`${this.registeredJobs.size} cron job(s) registered`);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
for (const [name] of this.registeredJobs) {
|
||||
await this.queue.removeJobScheduler(name);
|
||||
}
|
||||
this.logger.log(`removed ${this.registeredJobs.size} repeatable job(s)`);
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const stats: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
everyMs: number;
|
||||
lastExecution?: {
|
||||
status: string;
|
||||
started_at: Date;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
}> = [];
|
||||
|
||||
for (const [name, schedule] of Object.entries(CRON_SCHEDULES) as [CronJobName, (typeof CRON_SCHEDULES)[CronJobName]][]) {
|
||||
const last = await this.execRepo.findOne({
|
||||
where: { job_name: name },
|
||||
order: { started_at: 'DESC' },
|
||||
});
|
||||
stats.push({
|
||||
name,
|
||||
label: CRON_JOB_LABELS[name] ?? name,
|
||||
everyMs: schedule.everyMs,
|
||||
lastExecution: last
|
||||
? {
|
||||
status: last.status,
|
||||
started_at: last.started_at,
|
||||
duration_ms: last.duration_ms,
|
||||
error_message: last.error_message,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Queue, Worker, Job } from 'bullmq';
|
||||
import { Repository } from 'typeorm';
|
||||
import { JobExecution } from './job-execution.entity';
|
||||
|
||||
/**
|
||||
* أسماء المهام الدورية — مصدر الحقيقة الوحيد.
|
||||
* لا سلاسل نصية مبعثرة في الكود.
|
||||
*/
|
||||
export const CRON_JOBS = {
|
||||
SCHEDULED_TRIPS_SWEEP: 'cron:scheduled_trips_sweep',
|
||||
SEARCH_TIMEOUT_SWEEP: 'cron:search_timeout_sweep',
|
||||
REWARDS_SWEEP: 'cron:rewards_sweep',
|
||||
SURGE_RECALCULATE: 'cron:surge_recalculate',
|
||||
RE_ENGAGEMENT: 'cron:re_engagement',
|
||||
TRANSIT_APPROACHING_ALERTS: 'cron:transit_approaching_alerts',
|
||||
TRANSIT_SYNC_MEMBERS: 'cron:transit_sync_members',
|
||||
BOT_SOCIAL_TASKS: 'cron:bot_social_tasks',
|
||||
} as const;
|
||||
|
||||
export type CronJobName = (typeof CRON_JOBS)[keyof typeof CRON_JOBS];
|
||||
|
||||
/**
|
||||
* جدولة كل مهمة — كل مهمة لها فاصلها وحدّها الأقصى.
|
||||
*/
|
||||
export const CRON_SCHEDULES: Record<CronJobName, { everyMs: number; timeoutMs: number }> = {
|
||||
[CRON_JOBS.SCHEDULED_TRIPS_SWEEP]: { everyMs: 30_000, timeoutMs: 15_000 },
|
||||
[CRON_JOBS.SEARCH_TIMEOUT_SWEEP]: { everyMs: 60_000, timeoutMs: 30_000 },
|
||||
[CRON_JOBS.REWARDS_SWEEP]: { everyMs: 60_000, timeoutMs: 30_000 },
|
||||
[CRON_JOBS.SURGE_RECALCULATE]: { everyMs: 180_000, timeoutMs: 60_000 },
|
||||
[CRON_JOBS.RE_ENGAGEMENT]: { everyMs: 86_400_000, timeoutMs: 120_000 },
|
||||
[CRON_JOBS.TRANSIT_APPROACHING_ALERTS]: { everyMs: 60_000, timeoutMs: 30_000 },
|
||||
[CRON_JOBS.TRANSIT_SYNC_MEMBERS]: { everyMs: 86_400_000, timeoutMs: 120_000 },
|
||||
[CRON_JOBS.BOT_SOCIAL_TASKS]: { everyMs: 900_000, timeoutMs: 60_000 },
|
||||
};
|
||||
|
||||
/**
|
||||
* أسماء المهام الودية (للقسم).
|
||||
*/
|
||||
export const CRON_JOB_LABELS: Record<CronJobName, string> = {
|
||||
[CRON_JOBS.SCHEDULED_TRIPS_SWEEP]: 'Release scheduled trips',
|
||||
[CRON_JOBS.SEARCH_TIMEOUT_SWEEP]: 'Expire stuck searching trips',
|
||||
[CRON_JOBS.REWARDS_SWEEP]: 'Pay out qualified referrals',
|
||||
[CRON_JOBS.SURGE_RECALCULATE]: 'Recalculate surge multipliers',
|
||||
[CRON_JOBS.RE_ENGAGEMENT]: 'Re-engagement notifications',
|
||||
[CRON_JOBS.TRANSIT_APPROACHING_ALERTS]: 'Transit approaching station alerts',
|
||||
[CRON_JOBS.TRANSIT_SYNC_MEMBERS]: 'Sync transit member groups',
|
||||
[CRON_JOBS.BOT_SOCIAL_TASKS]: 'Execute social media bot tasks',
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Worker, Job } from 'bullmq';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CRON_JOBS, CronJobName } from './cron-registry';
|
||||
import { CRON_QUEUE } from './bull.module';
|
||||
import { JobExecution } from './job-execution.entity';
|
||||
import { TripsService } from '../../modules/trips/trips.service';
|
||||
import { ReferralsService } from '../../modules/rewards/referrals.service';
|
||||
import { SurgeService } from '../../modules/tariff/surge.service';
|
||||
import { MarketingService } from '../../modules/marketing/marketing.service';
|
||||
import { BotService } from '../../modules/bots/bot.service';
|
||||
|
||||
/**
|
||||
* معالج المهام الدورية (O5).
|
||||
*
|
||||
* يعمل في عملية **الـAPI** (ليس الـworker) لأنه يحتاج حقن الخدمات.
|
||||
* كل مهمة تكتب سجل تنفيذ في `job_executions`.
|
||||
*
|
||||
* قفل ضد التشغيل المزدوج: `concurrency: 1` في الـWorker.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CronWorkerService {
|
||||
private readonly logger = new Logger('CronWorker');
|
||||
private worker?: Worker;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(JobExecution)
|
||||
private readonly execRepo: Repository<JobExecution>,
|
||||
private readonly config: ConfigService,
|
||||
private readonly tripsService: TripsService,
|
||||
private readonly referralsService: ReferralsService,
|
||||
private readonly surgeService: SurgeService,
|
||||
private readonly marketingService: MarketingService,
|
||||
private readonly botService: BotService,
|
||||
) {}
|
||||
|
||||
start(): void {
|
||||
const connection = {
|
||||
host: this.config.get<string>('redis.host'),
|
||||
port: this.config.get<number>('redis.port'),
|
||||
db: this.config.get<number>('redis.db'),
|
||||
};
|
||||
|
||||
this.worker = new Worker(
|
||||
CRON_QUEUE,
|
||||
async (job: Job) => {
|
||||
const name = job.name as CronJobName;
|
||||
const execution = await this.startExecution(name);
|
||||
|
||||
try {
|
||||
let itemsProcessed = 0;
|
||||
|
||||
switch (name) {
|
||||
case CRON_JOBS.SCHEDULED_TRIPS_SWEEP:
|
||||
itemsProcessed = await this.tripsService.releaseDueScheduled();
|
||||
break;
|
||||
|
||||
case CRON_JOBS.SEARCH_TIMEOUT_SWEEP: {
|
||||
const timeoutMs = this.config.get<number>('trips.searchTimeoutMs') ?? 900_000;
|
||||
itemsProcessed = await this.tripsService.expireStuckSearches(timeoutMs);
|
||||
break;
|
||||
}
|
||||
|
||||
case CRON_JOBS.REWARDS_SWEEP:
|
||||
itemsProcessed = await this.referralsService.sweep();
|
||||
break;
|
||||
|
||||
case CRON_JOBS.SURGE_RECALCULATE: {
|
||||
const result = await this.surgeService.recalculateAll();
|
||||
itemsProcessed = result.updated + result.unchanged;
|
||||
break;
|
||||
}
|
||||
|
||||
case CRON_JOBS.RE_ENGAGEMENT: {
|
||||
const campaigns = await this.marketingService.findDueReEngagement('*', 3);
|
||||
let totalSent = 0;
|
||||
for (const campaign of campaigns) {
|
||||
const inactiveUserIds = await this.marketingService.findInactiveUserIds(
|
||||
campaign.tenant_id,
|
||||
3,
|
||||
);
|
||||
if (inactiveUserIds.length > 0) {
|
||||
const result = await this.marketingService.runCampaign(campaign.id, inactiveUserIds);
|
||||
totalSent += result.sent;
|
||||
}
|
||||
}
|
||||
itemsProcessed = totalSent;
|
||||
break;
|
||||
}
|
||||
|
||||
case CRON_JOBS.TRANSIT_APPROACHING_ALERTS: {
|
||||
this.logger.debug('transit approaching alerts: delegated to transit microservice');
|
||||
itemsProcessed = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case CRON_JOBS.TRANSIT_SYNC_MEMBERS: {
|
||||
this.logger.debug('transit sync members: delegated to transit microservice');
|
||||
itemsProcessed = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
case CRON_JOBS.BOT_SOCIAL_TASKS: {
|
||||
const dueTasks = await this.botService.getDueTasks();
|
||||
for (const task of dueTasks) {
|
||||
await this.botService.updateStatus(task.id, 'running');
|
||||
try {
|
||||
await this.botService.updateStatus(task.id, 'completed', {
|
||||
message: 'Task queued for execution',
|
||||
});
|
||||
} catch (e: any) {
|
||||
await this.botService.updateStatus(task.id, 'failed');
|
||||
}
|
||||
}
|
||||
itemsProcessed = dueTasks.length;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown cron job: ${name}`);
|
||||
}
|
||||
|
||||
await this.finishExecution(execution, 'success', itemsProcessed);
|
||||
if (itemsProcessed > 0) {
|
||||
this.logger.log(`${name}: processed ${itemsProcessed} item(s)`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
await this.finishExecution(execution, 'failed', 0, err?.message);
|
||||
this.logger.error(`${name} failed: ${err?.message}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
concurrency: 1,
|
||||
prefix: this.config.get<string>('queue.prefix'),
|
||||
},
|
||||
);
|
||||
|
||||
this.worker.on('ready', () => this.logger.log('cron worker ready'));
|
||||
this.worker.on('error', (err) => this.logger.error(`cron worker error: ${err.message}`));
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
this.worker = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async startExecution(jobName: string): Promise<JobExecution> {
|
||||
const execution = this.execRepo.create({
|
||||
job_name: jobName,
|
||||
status: 'running',
|
||||
started_at: new Date(),
|
||||
});
|
||||
return this.execRepo.save(execution);
|
||||
}
|
||||
|
||||
private async finishExecution(
|
||||
execution: JobExecution,
|
||||
status: 'success' | 'failed',
|
||||
itemsProcessed: number,
|
||||
errorMessage?: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
execution.status = status;
|
||||
execution.finished_at = now;
|
||||
execution.duration_ms = now.getTime() - execution.started_at.getTime();
|
||||
execution.items_processed = itemsProcessed;
|
||||
execution.error_message = errorMessage ?? null;
|
||||
await this.execRepo.save(execution);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JobExecution } from './job-execution.entity';
|
||||
import { BullModule } from './bull.module';
|
||||
import { CronOrchestratorService } from './cron-orchestrator.service';
|
||||
import { CronWorkerService } from './cron-worker.service';
|
||||
import { CronAdminController } from './cron-admin.controller';
|
||||
import { TripsModule } from '../../modules/trips/trips.module';
|
||||
import { RewardsModule } from '../../modules/rewards/rewards.module';
|
||||
import { TariffModule } from '../../modules/tariff/tariff.module';
|
||||
import { MarketingModule } from '../../modules/marketing/marketing.module';
|
||||
import { BotModule } from '../../modules/bots/bot.module';
|
||||
|
||||
/**
|
||||
* وحدة المهام الدورية الموحّدة (O5).
|
||||
*
|
||||
* تسجّل كل مهمة دورية كـrepeatable job في BullMQ، وتعالجها في عملية واحدة
|
||||
* مع `concurrency: 1` لمنع التشغيل المزدوج. كل تنفيذ يُسجَّل في
|
||||
* `job_executions` — لا مهمة صامتة.
|
||||
*
|
||||
* لا تُستهلك في الـworker — الـworker يبقى مستقلاً لمفرّغ المواقع (5 ثوانٍ).
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([JobExecution]),
|
||||
BullModule,
|
||||
TripsModule,
|
||||
RewardsModule,
|
||||
TariffModule,
|
||||
MarketingModule,
|
||||
BotModule,
|
||||
],
|
||||
controllers: [CronAdminController],
|
||||
providers: [CronOrchestratorService, CronWorkerService],
|
||||
exports: [CronOrchestratorService],
|
||||
})
|
||||
export class CronModule implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly orchestrator: CronOrchestratorService,
|
||||
private readonly worker: CronWorkerService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.worker.start();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.worker.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* سجل تنفيذ المهام الدورية (O5).
|
||||
*
|
||||
* الجدول: `tripz_job_executions`.
|
||||
*
|
||||
* كل صف = تنفيذ واحد لأي مهمة كانت (sweeper, bot, report...).
|
||||
* الهدف: إظهار آخر نجاح/فشل لكل مهمة + مدة التنفيذ + سبب الفشل.
|
||||
* لا مهمة صامتة — كل تنفيذ يُسجَّل هنا.
|
||||
*/
|
||||
@Entity('job_executions')
|
||||
@Index(['job_name', 'started_at'])
|
||||
export class JobExecution {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
/** اسم المهمة (cron:scheduled_trips_sweep, cron:surge_recalculate...). */
|
||||
@Column()
|
||||
job_name: string;
|
||||
|
||||
/** حالة التنفيذ. */
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
status: 'running' | 'success' | 'failed' | 'timeout';
|
||||
|
||||
/** لحظة بدء التنفيذ الفعلي (لا وقت إضاف الطور). */
|
||||
@Column({ type: 'timestamptz' })
|
||||
started_at: Date;
|
||||
|
||||
/** لحظة انتهاء التنفيذ. */
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
finished_at: Date | null;
|
||||
|
||||
/** مدة التنفيذ بالميلي ثانية. */
|
||||
@Column({ type: 'int', nullable: true })
|
||||
duration_ms: number;
|
||||
|
||||
/** عدد العناصر التي عالجتها المهمة (مثلاً: عدد الرحلات التي انتهت). */
|
||||
@Column({ type: 'int', default: 0 })
|
||||
items_processed: number;
|
||||
|
||||
/** رسالة خطأ (فقط عند الفشل). */
|
||||
@Column({ type: 'text', nullable: true })
|
||||
error_message: string | null;
|
||||
|
||||
/** معرّف المستأجر (إن كانت المهمة خاصة بمستأجر). */
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
tenant_id: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -6,17 +6,24 @@ import { TenantPlan } from '../../database/entities/tenant.entity';
|
||||
* هي الحقيقة النهائية.
|
||||
*/
|
||||
export const FEATURES = [
|
||||
'dispatch', // لوحة المشغّل — إنشاء رحلة نيابةً عن راكب
|
||||
'wallet', // محفظة الراكب/السائق + التسوية
|
||||
'payments', // بوابات الدفع والشحن
|
||||
'chat', // دردشة داخل الرحلة
|
||||
'calls', // مكالمات WebRTC
|
||||
'ride_types', // أنواع رحلات مخصّصة للمستأجر
|
||||
'market_intel', // الاستخبار السوقي
|
||||
'bots', // البوتات
|
||||
'ads', // الدعاية
|
||||
'transit', // نظام المواصلات
|
||||
'api_access', // API + Webhooks للمستأجر
|
||||
'dispatch', // لوحة المشغّل — إنشاء رحلة نيابةً عن راكب
|
||||
'wallet', // محفظة الراكب/السائق + التسوية
|
||||
'payments', // بوابات الدفع والشحن
|
||||
'chat', // دردشة داخل الرحلة
|
||||
'calls', // مكالمات WebRTC
|
||||
'ride_types', // أنواع رحلات مخصّصة للمستأجر
|
||||
'market_intel', // الاستخبار السوقي
|
||||
'bots', // البوتات
|
||||
'ads', // الدعاية
|
||||
'transit', // نظام المواصلات
|
||||
'api_access', // API + Webhooks للمستأجر
|
||||
'driver_tiers', // مستويات السائقين (برونزي/فضي/ذهبي/بلاتيني)
|
||||
'marketing_engine', // محرك التسويق التلقائي
|
||||
'dynamic_pricing', // التسعير الديناميكي (مضاعف الطلب)
|
||||
'geofence', // السياج الجغرافي — مناطق التسعير والتنبيهات
|
||||
'negotiator', // المفاوض الآلي — تفاوض السعر تلقائياً
|
||||
'driver_assurance', // تأمين السائق — تغطية تأمينية
|
||||
'coupons', // كوبونات الخصم
|
||||
] as const;
|
||||
|
||||
export type Feature = (typeof FEATURES)[number];
|
||||
@@ -45,7 +52,7 @@ const PLAN_DEFAULTS: Record<TenantPlan, { features: Feature[]; limits: Limits }>
|
||||
limits: { drivers_max: 50, cities_max: 1 },
|
||||
},
|
||||
brand: {
|
||||
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments'],
|
||||
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'driver_tiers'],
|
||||
limits: { drivers_max: 500, cities_max: 3 },
|
||||
},
|
||||
fleet: {
|
||||
|
||||
@@ -27,6 +27,7 @@ const ar: Catalog = {
|
||||
'trip.expired': { title: 'انتهت مهلة الطلب', body: 'انتهت مهلة طلب الرحلة دون قبول' },
|
||||
'trip.offer': { title: 'طلب رحلة جديد', body: 'راكب على بعد {distanceKm} كم — الأجرة {fare} {currency}' },
|
||||
'trip.offer_taken': { title: 'الرحلة لم تعد متاحة', body: 'قبِل الطلبَ سائق آخر' },
|
||||
'trip.audio_recording_started': { title: 'بدء التسجيل', body: 'تم بدء تسجيل الرحلة صوتياً لضمان سلامتكما' },
|
||||
'chat.message': { title: 'رسالة جديدة', body: '{preview}' },
|
||||
};
|
||||
|
||||
@@ -43,6 +44,7 @@ const en: Catalog = {
|
||||
'trip.expired': { title: 'Request expired', body: 'The ride request expired without being accepted' },
|
||||
'trip.offer': { title: 'New ride request', body: 'Rider {distanceKm} km away — fare {fare} {currency}' },
|
||||
'trip.offer_taken': { title: 'Ride no longer available', body: 'Another driver accepted the request' },
|
||||
'trip.audio_recording_started': { title: 'Recording started', body: 'Trip audio recording has started for your safety' },
|
||||
'chat.message': { title: 'New message', body: '{preview}' },
|
||||
};
|
||||
|
||||
|
||||
@@ -80,6 +80,16 @@ export default () => ({
|
||||
// الرحلات: كل كم يفحص كنس الرحلات المجدولة (docs/17 — B5).
|
||||
trips: {
|
||||
scheduleSweepMs: parseInt(process.env.TRIP_SCHEDULE_SWEEP_MS ?? '30000', 10),
|
||||
// R1 — كم يفحص رحلات searching عالقة (10 ثوانٍ كافية: لا ضغط على القاعدة).
|
||||
searchTimeoutSweepMs: parseInt(process.env.TRIP_SEARCH_TIMEOUT_SWEEP_MS ?? '10000', 10),
|
||||
// R1 — المهلة قبل إنهاء رحلة searching كـno_drivers (15 دقيقة كسيرو).
|
||||
searchTimeoutMs: parseInt(process.env.TRIP_SEARCH_TIMEOUT_MS ?? '900000', 10),
|
||||
// L4 — التسعير الديناميكي: كل كم يعيد حساب المضاعفات (3 دقائق افتراضياً).
|
||||
surgeSweepMs: parseInt(process.env.TRIP_SURGE_SWEEP_MS ?? '180000', 10),
|
||||
// L4 — الحدّ الأدنى بين تغييرين متتاليين للمضاعف (5 دقائق).
|
||||
surgeCooldownMs: parseInt(process.env.TRIP_SURGE_COOLDOWN_MS ?? '300000', 10),
|
||||
// L4 — حسّاسية الزيادة: 0.1 خفيفة، 0.3 متوسطة، 0.5 حادة.
|
||||
surgeSensitivity: parseFloat(process.env.TRIP_SURGE_SENSITIVITY ?? '0.3'),
|
||||
},
|
||||
|
||||
// المكافآت: كل كم يمسح الماسح الإحالات المؤهَّلة غير المصروفة (docs/17 — L5).
|
||||
@@ -115,15 +125,19 @@ export default () => ({
|
||||
baseUrl: process.env.STORAGE_BASE_URL ?? '',
|
||||
},
|
||||
|
||||
// خدمة المواصلات (microservice مستقل)
|
||||
transit: {
|
||||
serviceUrl: process.env.TRANSIT_SERVICE_URL ?? 'http://transit:4020',
|
||||
},
|
||||
|
||||
// المدفوعات: توقيع HMAC على العمليات المالية (docs/17 — I6).
|
||||
// مطفأ حتى يوقّع تطبيق فلاتر؛ تفعيله قبل ذلك يقطع كل سحب.
|
||||
payments: {
|
||||
requireSignature: process.env.PAYMENTS_REQUIRE_SIGNATURE === 'true',
|
||||
},
|
||||
|
||||
// إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط).
|
||||
// إشعارات FCM (ملف JSON لحزمة firebase-admin).
|
||||
fcm: {
|
||||
serverKey: process.env.FCM_SERVER_KEY ?? '',
|
||||
endpoint: process.env.FCM_ENDPOINT ?? 'https://fcm.googleapis.com/fcm/send',
|
||||
credentialsPath: process.env.GOOGLE_APPLICATION_CREDENTIALS ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* الجداول السبعة الجديدة + أعمدة الشرائح على tripz_drivers.
|
||||
*
|
||||
* الجداول:
|
||||
* - tripz_job_executions — سجل المهام الدورية (O5)
|
||||
* - tripz_surge_states — حالة التسعير الديناميكي (L4)
|
||||
* - tripz_bot_tasks — مهام بوتات التواصل (O4)
|
||||
* - tripz_feature_catalog — كتالوج الميزات القابلة للبيع (N6)
|
||||
* - tripz_ride_type_catalog — كتالوج أنواع الرحلات العالمي (L2)
|
||||
* - tripz_marketing_campaigns — حملات التسويق
|
||||
* - tripz_trip_audio — تسجيلات صوتية للرحلات (R2)
|
||||
*
|
||||
* أعمدة إضافية على tripz_drivers: tier, tier_score, total_trips, acceptance_rate.
|
||||
*/
|
||||
export class AddNewTablesAndDriverColumns1721970000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(q: QueryRunner): Promise<void> {
|
||||
// ─── 1. job_executions ────────────────────────────────────────
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_job_executions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_name varchar NOT NULL,
|
||||
status varchar(20) NOT NULL,
|
||||
started_at timestamptz NOT NULL,
|
||||
finished_at timestamptz,
|
||||
duration_ms int,
|
||||
items_processed int NOT NULL DEFAULT 0,
|
||||
error_message text,
|
||||
tenant_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_job_executions_job_started"
|
||||
ON tripz_job_executions (job_name, started_at)
|
||||
`);
|
||||
|
||||
// ─── 2. surge_states ──────────────────────────────────────────
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_surge_states (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
city varchar NOT NULL,
|
||||
service_class varchar NOT NULL,
|
||||
multiplier numeric(5,2) NOT NULL DEFAULT 1.0,
|
||||
max_multiplier numeric(5,2) NOT NULL DEFAULT 2.0,
|
||||
min_multiplier numeric(5,2) NOT NULL DEFAULT 1.0,
|
||||
demand_count int NOT NULL DEFAULT 0,
|
||||
supply_count int NOT NULL DEFAULT 0,
|
||||
last_sample_at timestamptz,
|
||||
last_changed_at timestamptz,
|
||||
manual_override boolean NOT NULL DEFAULT false,
|
||||
manual_note varchar,
|
||||
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_surge_states_tenant_city_class"
|
||||
ON tripz_surge_states (tenant_id, city, service_class)
|
||||
`);
|
||||
|
||||
// ─── 3. bot_tasks ─────────────────────────────────────────────
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_bot_tasks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
platform varchar NOT NULL,
|
||||
task_type varchar NOT NULL,
|
||||
status varchar NOT NULL DEFAULT 'pending',
|
||||
config jsonb NOT NULL DEFAULT '{}',
|
||||
result jsonb NOT NULL DEFAULT '{}',
|
||||
items_processed int NOT NULL DEFAULT 0,
|
||||
items_failed int NOT NULL DEFAULT 0,
|
||||
error_message text,
|
||||
started_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
// ─── 4. feature_catalog ───────────────────────────────────────
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_feature_catalog (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key varchar(64) NOT NULL,
|
||||
name_ar varchar NOT NULL,
|
||||
name_en varchar NOT NULL,
|
||||
category varchar NOT NULL DEFAULT 'add_on',
|
||||
monthly_price numeric(10,3) NOT NULL DEFAULT 0,
|
||||
setup_fee numeric(10,3) NOT NULL DEFAULT 0,
|
||||
currency varchar NOT NULL DEFAULT 'JOD',
|
||||
description_ar text,
|
||||
sort_order int NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
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_feature_catalog_key"
|
||||
ON tripz_feature_catalog (key)
|
||||
`);
|
||||
|
||||
// ─── 5. ride_type_catalog ─────────────────────────────────────
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_ride_type_catalog (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code varchar NOT NULL,
|
||||
name_ar varchar NOT NULL,
|
||||
name_en varchar,
|
||||
vehicle_kind varchar NOT NULL DEFAULT 'car',
|
||||
women_only boolean NOT NULL DEFAULT false,
|
||||
round_trip_supported boolean NOT NULL DEFAULT true,
|
||||
requires_weight boolean NOT NULL DEFAULT false,
|
||||
max_seats int NOT NULL DEFAULT 4,
|
||||
icon varchar,
|
||||
sort int NOT NULL DEFAULT 0,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
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_ride_type_catalog_code"
|
||||
ON tripz_ride_type_catalog (code)
|
||||
`);
|
||||
|
||||
// ─── 6. marketing_campaigns ───────────────────────────────────
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_marketing_campaigns (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
name varchar NOT NULL,
|
||||
type varchar NOT NULL,
|
||||
status varchar NOT NULL DEFAULT 'draft',
|
||||
title text,
|
||||
body text,
|
||||
data jsonb NOT NULL DEFAULT '{}',
|
||||
targeting jsonb NOT NULL DEFAULT '{}',
|
||||
sent_count int NOT NULL DEFAULT 0,
|
||||
open_count int NOT NULL DEFAULT 0,
|
||||
click_count int NOT NULL DEFAULT 0,
|
||||
scheduled_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
|
||||
// ─── 7. trip_audio ────────────────────────────────────────────
|
||||
await q.query(`
|
||||
CREATE TABLE IF NOT EXISTS tripz_trip_audio (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL,
|
||||
trip_id uuid NOT NULL,
|
||||
uploaded_by varchar NOT NULL,
|
||||
file_key varchar NOT NULL,
|
||||
file_ext varchar NOT NULL DEFAULT 'm4a',
|
||||
duration_sec int,
|
||||
file_size bigint,
|
||||
destination_name varchar,
|
||||
origin_lat double precision,
|
||||
origin_lng double precision,
|
||||
dest_lat double precision,
|
||||
dest_lng double precision,
|
||||
trip_status_at_upload varchar,
|
||||
uploaded_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_audio_tenant_trip"
|
||||
ON tripz_trip_audio (tenant_id, trip_id)
|
||||
`);
|
||||
await q.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_tripz_trip_audio_trip"
|
||||
ON tripz_trip_audio (trip_id)
|
||||
`);
|
||||
|
||||
// ─── 8. أعمدة الشرائح على tripz_drivers ──────────────────────
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_drivers
|
||||
ADD COLUMN IF NOT EXISTS tier varchar NOT NULL DEFAULT 'bronze',
|
||||
ADD COLUMN IF NOT EXISTS tier_score int NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS total_trips int NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS acceptance_rate numeric(5,2) NOT NULL DEFAULT 100
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(q: QueryRunner): Promise<void> {
|
||||
// إزالة أعمدة الشرائح
|
||||
await q.query(`
|
||||
ALTER TABLE tripz_drivers
|
||||
DROP COLUMN IF EXISTS acceptance_rate,
|
||||
DROP COLUMN IF EXISTS total_trips,
|
||||
DROP COLUMN IF EXISTS tier_score,
|
||||
DROP COLUMN IF EXISTS tier
|
||||
`);
|
||||
|
||||
// حذف الجداول (الترتيب العكسي للعلاقات)
|
||||
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trip_audio_trip"`);
|
||||
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_trip_audio_tenant_trip"`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_trip_audio`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_marketing_campaigns`);
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_ride_type_catalog_code"`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_ride_type_catalog`);
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_feature_catalog_key"`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_feature_catalog`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_bot_tasks`);
|
||||
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_surge_states_tenant_city_class"`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_surge_states`);
|
||||
await q.query(`DROP INDEX IF EXISTS "IDX_tripz_job_executions_job_started"`);
|
||||
await q.query(`DROP TABLE IF EXISTS tripz_job_executions`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import {
|
||||
PaymentAdapter,
|
||||
ChargeContext,
|
||||
ChargeResult,
|
||||
WebhookResult,
|
||||
} from './payment-adapter.interface';
|
||||
|
||||
/**
|
||||
* بوابة CliQ — الدفع الإلكتروني الأردني (BOK + بنوك الأردن).
|
||||
*
|
||||
* تدفق العمل:
|
||||
* 1. `POST /api/v1/payment` — إنشاء عملية دفع.
|
||||
* 2. يُرجع `paymentUrl` — يُفتح في المتصفح/iframe.
|
||||
* 3. webhook موقَّع يؤكد الدفع.
|
||||
*
|
||||
* الاعتمادات تُقرأ من `tenant.settings.payments.cliq` أولاً، ثم env vars.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CliqAdapter implements PaymentAdapter {
|
||||
readonly name = 'cliq';
|
||||
private readonly logger = new Logger('CliQ');
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('cliq.baseUrl') ?? 'https://api.cliq.jo';
|
||||
}
|
||||
|
||||
private creds(tenant: Tenant) {
|
||||
const t = tenant?.settings?.payments?.cliq ?? {};
|
||||
return {
|
||||
merchantId: t.merchantId ?? this.config.get<string>('cliq.merchantId') ?? '',
|
||||
apiKey: t.apiKey ?? this.config.get<string>('cliq.apiKey') ?? '',
|
||||
secretKey: t.secretKey ?? this.config.get<string>('cliq.secretKey') ?? '',
|
||||
terminalId: t.terminalId ?? this.config.get<string>('cliq.terminalId') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
|
||||
const creds = this.creds(tenant);
|
||||
if (!creds.merchantId || !creds.apiKey) {
|
||||
this.logger.warn('CliQ credentials not configured — falling back to invoice');
|
||||
return {
|
||||
mode: 'invoice',
|
||||
reference: `CLIQ-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`,
|
||||
instructions: 'CliQ credentials not configured',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${creds.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
merchant_id: creds.merchantId,
|
||||
terminal_id: creds.terminalId,
|
||||
amount: ctx.amount,
|
||||
currency: ctx.currency,
|
||||
order_id: ctx.paymentId,
|
||||
customer_phone: ctx.userPhone ?? '',
|
||||
callback_url: `${this.config.get<string>('app.baseUrl') ?? 'https://api.tripz.app'}/webhooks/payments/cliq`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
this.logger.error(`CliQ charge failed: ${res.status} ${body}`);
|
||||
throw new Error(`CliQ API ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
mode: 'redirect',
|
||||
redirectUrl: data.paymentUrl ?? data.redirect_url ?? data.url,
|
||||
providerRef: data.payment_id ?? data.id,
|
||||
};
|
||||
} catch (e: any) {
|
||||
this.logger.error(`CliQ charge error: ${e?.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean {
|
||||
const creds = this.creds(tenant);
|
||||
const signature = headers['x-cliq-signature'] ?? headers['x-signature'] ?? '';
|
||||
if (!signature || !creds.secretKey) return false;
|
||||
|
||||
// HMAC-SHA256 على الجسم الخام — مطابق لنمط PayMob.
|
||||
const { createHmac, timingSafeEqual } = require('crypto');
|
||||
const expected = createHmac('sha256', creds.secretKey)
|
||||
.update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody))
|
||||
.digest('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
parseWebhook(payload: any): WebhookResult | null {
|
||||
if (!payload) return null;
|
||||
const status = payload.status ?? payload.payment_status;
|
||||
if (!status) return null;
|
||||
return {
|
||||
providerRef: payload.payment_id ?? payload.id ?? payload.transaction_id ?? '',
|
||||
success: status === 'success' || status === 'completed' || status === 'paid',
|
||||
amount: payload.amount ? Number(payload.amount) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { InvoiceAdapter } from './invoice.adapter';
|
||||
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||
import { CashAdapter } from './cash.adapter';
|
||||
import { PaymobAdapter } from './paymob.adapter';
|
||||
import { SyriatelAdapter } from './syriatel.adapter';
|
||||
import { MtnAdapter } from './mtn.adapter';
|
||||
|
||||
describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)', () => {
|
||||
it('يولّد مرجعاً ويعرض حساب الاستلام المضبوط للمستأجر', async () => {
|
||||
@@ -45,7 +47,12 @@ describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)
|
||||
|
||||
describe('PaymentGatewayRegistry — يربط الكتالوج بمحوّله', () => {
|
||||
const config = { get: () => undefined } as any;
|
||||
const registry = () => new PaymentGatewayRegistry(new CashAdapter(), new PaymobAdapter(config));
|
||||
const registry = () => new PaymentGatewayRegistry(
|
||||
new CashAdapter(),
|
||||
new PaymobAdapter(config),
|
||||
new SyriatelAdapter(config),
|
||||
new MtnAdapter(config),
|
||||
);
|
||||
|
||||
it('يعرف كل مزوّدي الكتالوج', () => {
|
||||
const r = registry();
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import {
|
||||
PaymentAdapter,
|
||||
ChargeContext,
|
||||
ChargeResult,
|
||||
WebhookResult,
|
||||
} from './payment-adapter.interface';
|
||||
|
||||
/**
|
||||
* بوابة MTN للدفع الإلكتروني — MTN Mobile Money / MTN Payment Gateway.
|
||||
*
|
||||
* تدفق العمل:
|
||||
* 1. `POST /api/v1/requestToPay` — طلب دفع من المستخدم.
|
||||
* 2. يُرجع `referenceId` — التطبيق يتابع الحالة عبر polling.
|
||||
* 3. webhook / polling يؤكد النجاح.
|
||||
*
|
||||
* الاعتمادات تُقرأ من `tenant.settings.payments.mtn` أولاً، ثم env vars.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MtnAdapter implements PaymentAdapter {
|
||||
readonly name = 'mtn';
|
||||
private readonly logger = new Logger('MTN');
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('mtn.baseUrl') ?? 'https://proxy.momoapi.mtn.com';
|
||||
}
|
||||
|
||||
private creds(tenant: Tenant) {
|
||||
const t = tenant?.settings?.payments?.mtn ?? {};
|
||||
return {
|
||||
apiKey: t.apiKey ?? this.config.get<string>('mtn.apiKey') ?? '',
|
||||
apiUser: t.apiUser ?? this.config.get<string>('mtn.apiUser') ?? '',
|
||||
subscriptionKey: t.subscriptionKey ?? this.config.get<string>('mtn.subscriptionKey') ?? '',
|
||||
environment: t.environment ?? this.config.get<string>('mtn.environment') ?? 'sandbox',
|
||||
};
|
||||
}
|
||||
|
||||
private async getAccessToken(creds: { apiKey: string; apiUser: string; subscriptionKey: string }): Promise<string> {
|
||||
const res = await fetch(`${this.baseUrl}/collection/token/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`${creds.apiUser}:${creds.apiKey}`).toString('base64')}`,
|
||||
'Ocp-Apim-Subscription-Key': creds.subscriptionKey,
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`MTN token failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
|
||||
const creds = this.creds(tenant);
|
||||
if (!creds.apiKey || !creds.apiUser) {
|
||||
this.logger.warn('MTN credentials not configured — falling back to invoice');
|
||||
return {
|
||||
mode: 'invoice',
|
||||
reference: `MTN-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`,
|
||||
instructions: 'MTN credentials not configured',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await this.getAccessToken(creds);
|
||||
const referenceId = ctx.paymentId;
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/collection/v1_0/requestToPay`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Reference-Id': referenceId,
|
||||
'X-Target-Environment': creds.environment,
|
||||
'Ocp-Apim-Subscription-Key': creds.subscriptionKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: String(ctx.amount),
|
||||
currency: ctx.currency,
|
||||
externalId: ctx.paymentId,
|
||||
payer: { partyIdType: 'MSISDN', partyId: ctx.userPhone ?? '' },
|
||||
payerMessage: 'Tripz payment',
|
||||
payeeNote: `Payment for trip ${ctx.paymentId}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok && res.status !== 202) {
|
||||
const body = await res.text();
|
||||
this.logger.error(`MTN charge failed: ${res.status} ${body}`);
|
||||
throw new Error(`MTN API ${res.status}`);
|
||||
}
|
||||
|
||||
// MTN يرجع 202 Accepted — التأكيد عبر polling أو webhook.
|
||||
return {
|
||||
mode: 'redirect',
|
||||
redirectUrl: `${this.baseUrl}/collection/v1_0/requestToPay/${referenceId}`,
|
||||
providerRef: referenceId,
|
||||
};
|
||||
} catch (e: any) {
|
||||
this.logger.error(`MTN charge error: ${e?.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean {
|
||||
const creds = this.creds(tenant);
|
||||
const signature = headers['x-mtn-signature'] ?? headers['x-signature'] ?? '';
|
||||
if (!signature || !creds.subscriptionKey) return false;
|
||||
|
||||
const { createHmac, timingSafeEqual } = require('crypto');
|
||||
const expected = createHmac('sha256', creds.subscriptionKey)
|
||||
.update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody))
|
||||
.digest('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
parseWebhook(payload: any): WebhookResult | null {
|
||||
if (!payload) return null;
|
||||
const status = payload.status ?? payload.financialTransaction?.status;
|
||||
if (!status) return null;
|
||||
return {
|
||||
providerRef: payload.referenceId ?? payload.externalId ?? '',
|
||||
success: status === 'SUCCESSFUL' || status === 'completed',
|
||||
amount: payload.amount ? Number(payload.amount) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { PaymentAdapter } from './payment-adapter.interface';
|
||||
import { CashAdapter } from './cash.adapter';
|
||||
import { PaymobAdapter } from './paymob.adapter';
|
||||
import { InvoiceAdapter } from './invoice.adapter';
|
||||
import { SyriatelAdapter } from './syriatel.adapter';
|
||||
import { MtnAdapter } from './mtn.adapter';
|
||||
|
||||
/**
|
||||
* يربط اسم المزوّد (من كتالوج `payment-methods.ts`) بمحوّله (docs/07).
|
||||
@@ -13,11 +15,18 @@ import { InvoiceAdapter } from './invoice.adapter';
|
||||
export class PaymentGatewayRegistry {
|
||||
private readonly adapters = new Map<string, PaymentAdapter>();
|
||||
|
||||
constructor(cash: CashAdapter, paymob: PaymobAdapter) {
|
||||
constructor(
|
||||
cash: CashAdapter,
|
||||
paymob: PaymobAdapter,
|
||||
syriatel: SyriatelAdapter,
|
||||
mtn: MtnAdapter,
|
||||
) {
|
||||
this.adapters.set(cash.name, cash);
|
||||
this.adapters.set(paymob.name, paymob);
|
||||
// بلا API فعلية اليوم — محوّل مشترك واحد لكل منها (invoice.adapter.ts).
|
||||
for (const provider of ['cliq', 'shamcash', 'mtn', 'syriatel', 'zaincash']) {
|
||||
this.adapters.set(syriatel.name, syriatel);
|
||||
this.adapters.set(mtn.name, mtn);
|
||||
// CliQ وشام كاش وزين كاش — فواتير (بلا API فعلية)
|
||||
for (const provider of ['cliq', 'shamcash', 'zaincash']) {
|
||||
this.adapters.set(provider, new InvoiceAdapter(provider));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CashAdapter } from './cash.adapter';
|
||||
import { PaymobAdapter } from './paymob.adapter';
|
||||
import { SyriatelAdapter } from './syriatel.adapter';
|
||||
import { MtnAdapter } from './mtn.adapter';
|
||||
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||
|
||||
@Module({
|
||||
providers: [CashAdapter, PaymobAdapter, PaymentGatewayRegistry],
|
||||
providers: [CashAdapter, PaymobAdapter, SyriatelAdapter, MtnAdapter, PaymentGatewayRegistry],
|
||||
exports: [PaymentGatewayRegistry],
|
||||
})
|
||||
export class PaymentGatewaysModule {}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Tenant } from '../../database/entities/tenant.entity';
|
||||
import {
|
||||
PaymentAdapter,
|
||||
ChargeContext,
|
||||
ChargeResult,
|
||||
WebhookResult,
|
||||
} from './payment-adapter.interface';
|
||||
|
||||
/**
|
||||
* بوابة سيرياتيل للدفع الإلكتروني — SyriaTel e-payment.
|
||||
*
|
||||
* تدفق العمل:
|
||||
* 1. `POST /api/payment/init` — بدء عملية الدفع.
|
||||
* 2. يُرجع `payment_link` — يُفتح في المتصفح.
|
||||
* 3. webhook يؤكد النجاح/الفشل.
|
||||
*
|
||||
* الاعتمادات تُقرأ من `tenant.settings.payments.syriatel` أولاً، ثم env vars.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SyriatelAdapter implements PaymentAdapter {
|
||||
readonly name = 'syriatel';
|
||||
private readonly logger = new Logger('SyriaTel');
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return this.config.get<string>('syriatel.baseUrl') ?? 'https://e-payment.syriatel.com/api';
|
||||
}
|
||||
|
||||
private creds(tenant: Tenant) {
|
||||
const t = tenant?.settings?.payments?.syriatel ?? {};
|
||||
return {
|
||||
merchantCode: t.merchantCode ?? this.config.get<string>('syriatel.merchantCode') ?? '',
|
||||
apiKey: t.apiKey ?? this.config.get<string>('syriatel.apiKey') ?? '',
|
||||
secretKey: t.secretKey ?? this.config.get<string>('syriatel.secretKey') ?? '',
|
||||
serviceId: t.serviceId ?? this.config.get<string>('syriatel.serviceId') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async charge(ctx: ChargeContext, tenant: Tenant): Promise<ChargeResult> {
|
||||
const creds = this.creds(tenant);
|
||||
if (!creds.merchantCode || !creds.apiKey) {
|
||||
this.logger.warn('SyriaTel credentials not configured — falling back to invoice');
|
||||
return {
|
||||
mode: 'invoice',
|
||||
reference: `SYR-${ctx.paymentId.slice(0, 8)}-${Date.now().toString(36)}`,
|
||||
instructions: 'SyriaTel credentials not configured',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/payment/init`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${creds.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
merchant_code: creds.merchantCode,
|
||||
service_id: creds.serviceId,
|
||||
amount: ctx.amount,
|
||||
currency: ctx.currency,
|
||||
order_id: ctx.paymentId,
|
||||
customer_phone: ctx.userPhone ?? '',
|
||||
return_url: `${this.config.get<string>('app.baseUrl') ?? 'https://api.tripz.app'}/webhooks/payments/syriatel`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
this.logger.error(`SyriaTel charge failed: ${res.status} ${body}`);
|
||||
throw new Error(`SyriaTel API ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
mode: 'redirect',
|
||||
redirectUrl: data.payment_link ?? data.redirect_url ?? data.url,
|
||||
providerRef: data.payment_id ?? data.id,
|
||||
};
|
||||
} catch (e: any) {
|
||||
this.logger.error(`SyriaTel charge error: ${e?.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
verifyWebhook(headers: Record<string, any>, rawBody: any, tenant: Tenant): boolean {
|
||||
const creds = this.creds(tenant);
|
||||
const signature = headers['x-syriatel-signature'] ?? headers['x-signature'] ?? '';
|
||||
if (!signature || !creds.secretKey) return false;
|
||||
|
||||
const { createHmac, timingSafeEqual } = require('crypto');
|
||||
const expected = createHmac('sha256', creds.secretKey)
|
||||
.update(typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody))
|
||||
.digest('hex');
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
parseWebhook(payload: any): WebhookResult | null {
|
||||
if (!payload) return null;
|
||||
const status = payload.status ?? payload.payment_status;
|
||||
if (!status) return null;
|
||||
return {
|
||||
providerRef: payload.payment_id ?? payload.id ?? payload.transaction_id ?? '',
|
||||
success: status === 'success' || status === 'completed' || status === 'paid',
|
||||
amount: payload.amount ? Number(payload.amount) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
||||
import { BotService } from './bot.service';
|
||||
import { BotPlatform, BotTaskType } from './entities/bot-task.entity';
|
||||
|
||||
@ApiTags('bots')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard)
|
||||
@Roles('admin')
|
||||
@Controller('admin/bots')
|
||||
@RequiresFeature('bots')
|
||||
export class BotController {
|
||||
constructor(private readonly botService: BotService) {}
|
||||
|
||||
@Get('tasks')
|
||||
async listTasks(@CurrentUser() user: AuthUser) {
|
||||
return this.botService.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('tasks/:id')
|
||||
async getTask(@Param('id') id: string) {
|
||||
return this.botService.findOne(id);
|
||||
}
|
||||
|
||||
@Post('tasks')
|
||||
async createTask(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: {
|
||||
platform: BotPlatform;
|
||||
task_type: BotTaskType;
|
||||
config?: Record<string, any>;
|
||||
},
|
||||
) {
|
||||
return this.botService.createTask({
|
||||
tenant_id: user.tenantId,
|
||||
platform: body.platform,
|
||||
task_type: body.task_type,
|
||||
config: body.config || {},
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('tasks/:id/cancel')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async cancelTask(@Param('id') id: string) {
|
||||
return this.botService.cancelTask(id);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
async getStats(@CurrentUser() user: AuthUser) {
|
||||
return this.botService.getStats(user.tenantId);
|
||||
}
|
||||
|
||||
@Delete('tasks/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteTask(@Param('id') id: string) {
|
||||
await this.botService.cancelTask(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BotTask } from './entities/bot-task.entity';
|
||||
import { BotService } from './bot.service';
|
||||
import { BotController } from './bot.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BotTask])],
|
||||
controllers: [BotController],
|
||||
providers: [BotService],
|
||||
exports: [BotService],
|
||||
})
|
||||
export class BotModule {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BotTask, BotPlatform, BotTaskType, BotTaskStatus } from './entities/bot-task.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BotService {
|
||||
private readonly logger = new Logger(BotService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(BotTask)
|
||||
private readonly taskRepo: Repository<BotTask>,
|
||||
) {}
|
||||
|
||||
async createTask(data: Partial<BotTask>): Promise<BotTask> {
|
||||
const task = this.taskRepo.create(data);
|
||||
return this.taskRepo.save(task);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string): Promise<BotTask[]> {
|
||||
return this.taskRepo.find({
|
||||
where: { tenant_id: tenantId },
|
||||
order: { created_at: 'DESC' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<BotTask | null> {
|
||||
return this.taskRepo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: BotTaskStatus, result?: Record<string, any>): Promise<BotTask> {
|
||||
const update: any = { status };
|
||||
if (status === 'running') update.started_at = new Date();
|
||||
if (status === 'completed' || status === 'failed') update.completed_at = new Date();
|
||||
if (result) update.result = result;
|
||||
await this.taskRepo.update(id, update);
|
||||
return this.taskRepo.findOneOrFail({ where: { id } });
|
||||
}
|
||||
|
||||
async getStats(tenantId: string): Promise<{
|
||||
total: number;
|
||||
by_status: Record<BotTaskStatus, number>;
|
||||
by_platform: Record<BotPlatform, number>;
|
||||
}> {
|
||||
const tasks = await this.taskRepo.find({ where: { tenant_id: tenantId } });
|
||||
|
||||
const by_status: any = { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 };
|
||||
const by_platform: any = { facebook: 0, instagram: 0, telegram: 0, whatsapp: 0 };
|
||||
|
||||
for (const task of tasks) {
|
||||
by_status[task.status]++;
|
||||
by_platform[task.platform]++;
|
||||
}
|
||||
|
||||
return { total: tasks.length, by_status, by_platform };
|
||||
}
|
||||
|
||||
async getDueTasks(): Promise<BotTask[]> {
|
||||
return this.taskRepo.find({
|
||||
where: { status: 'pending' },
|
||||
order: { created_at: 'ASC' },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
|
||||
async cancelTask(id: string): Promise<BotTask> {
|
||||
return this.updateStatus(id, 'cancelled');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
export type BotPlatform = 'facebook' | 'instagram' | 'telegram' | 'whatsapp';
|
||||
export type BotTaskType = 'scroll_and_reply' | 'scrape' | 'like' | 'comment' | 'post' | 'message';
|
||||
export type BotTaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
@Entity('bot_tasks')
|
||||
export class BotTask {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
platform: BotPlatform;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
task_type: BotTaskType;
|
||||
|
||||
@Column({ type: 'varchar', default: 'pending' })
|
||||
status: BotTaskStatus;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
config: Record<string, any>;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
result: Record<string, any>;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
items_processed: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
items_failed: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
error_message: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
started_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
completed_at: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { PlatformGuard } from '../../common/platform/platform.guard';
|
||||
import { TenantPlan } from '../../database/entities/tenant.entity';
|
||||
|
||||
/**
|
||||
* كتالوج الميزات — يديره السوبر-أدن (docs/22 — N6/N7).
|
||||
*
|
||||
* كل المسارات خلف `PlatformGuard` (x-platform-secret). لا يمكن لأدمن
|
||||
* مستأجر الوصول إلى هذه النقط.
|
||||
*/
|
||||
@ApiTags('catalog')
|
||||
@Controller()
|
||||
export class CatalogController {
|
||||
constructor(private readonly catalog: CatalogService) {}
|
||||
|
||||
/** كل عناصر الكتالوج. */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/catalog')
|
||||
list() {
|
||||
return this.catalog.list();
|
||||
}
|
||||
|
||||
/** تحديث سعر أو حالة عنصر. */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Patch('admin/catalog/:id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { monthly_price?: number; setup_fee?: number; name_ar?: string; description_ar?: string; active?: boolean; sort_order?: number },
|
||||
) {
|
||||
return this.catalog.update(id, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* حاسبة العرض (N7): باقة + ميزات إضافية → سعر شهري + تأسيس.
|
||||
*
|
||||
* الاستخدام: السوبر-أدن يختار الباقة والميزات ثم يولّد عرضاً للعميل.
|
||||
*/
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Post('admin/catalog/quote')
|
||||
quote(
|
||||
@Body() body: { plan: TenantPlan; addOns?: string[]; currency?: string },
|
||||
) {
|
||||
return this.catalog.quote(body.plan, body.addOns ?? [], body.currency ?? 'JOD');
|
||||
}
|
||||
|
||||
// ─── كتالوج أنواع الرحلات العالمي (L2) ──────────────────────────────
|
||||
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/ride-type-catalog')
|
||||
listRideTypes(@Query('vehicle_kind') vehicleKind?: string) {
|
||||
return this.catalog.listRideTypes(vehicleKind);
|
||||
}
|
||||
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/ride-type-catalog/:code')
|
||||
getRideType(@Param('code') code: string) {
|
||||
return this.catalog.findRideTypeByCode(code);
|
||||
}
|
||||
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Post('admin/ride-type-catalog')
|
||||
createRideType(
|
||||
@Body() body: {
|
||||
code: string;
|
||||
name_ar: string;
|
||||
name_en?: string;
|
||||
vehicle_kind?: string;
|
||||
women_only?: boolean;
|
||||
round_trip_supported?: boolean;
|
||||
requires_weight?: boolean;
|
||||
max_seats?: number;
|
||||
icon?: string;
|
||||
sort?: number;
|
||||
},
|
||||
) {
|
||||
return this.catalog.createRideType(body);
|
||||
}
|
||||
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Patch('admin/ride-type-catalog/:id')
|
||||
updateRideType(
|
||||
@Param('id') id: string,
|
||||
@Body() body: {
|
||||
name_ar?: string;
|
||||
name_en?: string;
|
||||
vehicle_kind?: string;
|
||||
women_only?: boolean;
|
||||
round_trip_supported?: boolean;
|
||||
requires_weight?: boolean;
|
||||
max_seats?: number;
|
||||
icon?: string;
|
||||
sort?: number;
|
||||
active?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.catalog.updateRideType(id, body);
|
||||
}
|
||||
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Patch('admin/ride-type-catalog/:id/toggle')
|
||||
toggleRideType(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { active: boolean },
|
||||
) {
|
||||
return this.catalog.toggleRideType(id, body.active);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FeatureCatalog } from './entities/feature-catalog.entity';
|
||||
import { RideTypeCatalog } from './entities/ride-type-catalog.entity';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
|
||||
/**
|
||||
* وحدة الكتالوج العالمي (N6/N7 + L2).
|
||||
* تزرع البيانات الأولية عند أول تشغيل وتُغذّي مولّد العروض.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FeatureCatalog, RideTypeCatalog])],
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
})
|
||||
export class CatalogModule implements OnModuleInit {
|
||||
constructor(private readonly catalog: CatalogService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.catalog.seed();
|
||||
await this.catalog.seedRideTypes();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FeatureCatalog } from './entities/feature-catalog.entity';
|
||||
import { RideTypeCatalog } from './entities/ride-type-catalog.entity';
|
||||
import { RIDE_TYPE_CATALOG_SEED } from './ride-type-catalog.seed';
|
||||
import { TenantPlan } from '../../database/entities/tenant.entity';
|
||||
|
||||
/**
|
||||
* البيانات الأولية لكتالوج الميزات — تُزرع عند أول تشغيل (docs/22 — N6).
|
||||
* الأسعار بالدينار الأردني،قابلة للتعديل من لوحة السوبر-أدن.
|
||||
*/
|
||||
const SEED_CATALOG: Omit<FeatureCatalog, 'id' | 'created_at' | 'updated_at'>[] = [
|
||||
// ---- أساسيات (core) — مشمولة بالباقة، سعرها صفر ----
|
||||
{ key: 'wallet', name_ar: 'محفظة', name_en: 'Wallet', category: 'core', monthly_price: 0, setup_fee: 0, currency: 'JOD', description_ar: 'محفظة الراكب/السائق والتسوية', sort_order: 1, active: true },
|
||||
{ key: 'chat', name_ar: 'دردشة', name_en: 'Chat', category: 'core', monthly_price: 0, setup_fee: 0, currency: 'JOD', description_ar: 'محادثة داخل الرحلة', sort_order: 2, active: true },
|
||||
{ key: 'ride_types', name_ar: 'أنواع الرحلات', name_en: 'Ride Types', category: 'core', monthly_price: 0, setup_fee: 0, currency: 'JOD', description_ar: 'فئات خدمة مخصصة للمستأجر', sort_order: 3, active: true },
|
||||
|
||||
// ---- إضافات مدفوعة (add_on) ----
|
||||
{ key: 'payments', name_ar: 'بوابات الدفع', name_en: 'Payment Gateways', category: 'add_on', monthly_price: 15, setup_fee: 50, currency: 'JOD', description_ar: 'ربط بوابات الدفع الإلكترونية', sort_order: 10, active: true },
|
||||
{ key: 'calls', name_ar: 'مكالمات', name_en: 'WebRTC Calls', category: 'add_on', monthly_price: 10, setup_fee: 20, currency: 'JOD', description_ar: 'مكالمات صوتية داخل التطبيق', sort_order: 11, active: true },
|
||||
{ key: 'dispatch', name_ar: 'لوحة المشغّل', name_en: 'Dispatch', category: 'add_on', monthly_price: 20, setup_fee: 30, currency: 'JOD', description_ar: 'إنشاء رحلات نيابةً عن الركاب', sort_order: 12, active: true },
|
||||
{ key: 'market_intel', name_ar: 'الاستخبار السوقي', name_en: 'Market Intelligence', category: 'add_on', monthly_price: 25, setup_fee: 0, currency: 'JOD', description_ar: 'خرائط حرارية + تحليلات الطلب', sort_order: 13, active: true },
|
||||
{ key: 'bots', name_ar: 'البوتات', name_en: 'Bots', category: 'add_on', monthly_price: 20, setup_fee: 0, currency: 'JOD', description_ar: 'بوتات واتساب وسوشيال', sort_order: 14, active: true },
|
||||
{ key: 'ads', name_ar: 'الدعاية المستهدفة', name_en: 'Targeted Ads', category: 'add_on', monthly_price: 15, setup_fee: 0, currency: 'JOD', description_ar: 'حملات إعلانية مستهدفة داخل التطبيق', sort_order: 15, active: true },
|
||||
{ key: 'transit', name_ar: 'المواصلات', name_en: 'Transit', category: 'add_on', monthly_price: 30, setup_fee: 100, currency: 'JOD', description_ar: 'نظام خطوط المواصلات الجماعية', sort_order: 16, active: true },
|
||||
{ key: 'api_access', name_ar: 'الوصول لـ API', name_en: 'API Access', category: 'add_on', monthly_price: 10, setup_fee: 0, currency: 'JOD', description_ar: 'API + Webhooks للمستأجر', sort_order: 17, active: true },
|
||||
{ key: 'dynamic_pricing', name_ar: 'التسعير الديناميكي', name_en: 'Dynamic Pricing', category: 'add_on', monthly_price: 20, setup_fee: 0, currency: 'JOD', description_ar: 'مضاعف الطلب والمنطقة الزمنية', sort_order: 18, active: true },
|
||||
{ key: 'geofence', name_ar: 'السياج الجغرافي', name_en: 'Geofence', category: 'add_on', monthly_price: 10, setup_fee: 15, currency: 'JOD', description_ar: 'مناطق التسعير والتنبيهات', sort_order: 19, active: true },
|
||||
{ key: 'negotiator', name_ar: 'المفاوض الآلي', name_en: 'Negotiator', category: 'add_on', monthly_price: 15, setup_fee: 0, currency: 'JOD', description_ar: 'تفاوض السعر تلقائياً مع السائقين', sort_order: 20, active: true },
|
||||
{ key: 'driver_tiers', name_ar: 'شرائح السائقين', name_en: 'Driver Tiers', category: 'add_on', monthly_price: 10, setup_fee: 0, currency: 'JOD', description_ar: 'تصنيف السائقين حسب الأداء', sort_order: 21, active: true },
|
||||
{ key: 'driver_assurance', name_ar: 'تأمين السائق', name_en: 'Driver Assurance', category: 'add_on', monthly_price: 15, setup_fee: 0, currency: 'JOD', description_ar: 'تغطية تأمينية للسائقين', sort_order: 22, active: true },
|
||||
{ key: 'coupons', name_ar: 'الكوبونات', name_en: 'Coupons', category: 'add_on', monthly_price: 5, setup_fee: 0, currency: 'JOD', description_ar: 'محرك كوبونات الخصم', sort_order: 23, active: true },
|
||||
{ key: 'marketing_engine', name_ar: 'محرك التسويق', name_en: 'Marketing Engine', category: 'add_on', monthly_price: 20, setup_fee: 0, currency: 'JOD', description_ar: 'حملات إعلانية تلقائية وإعادة تفاعل', sort_order: 24, active: true },
|
||||
];
|
||||
|
||||
/** رسوم الباقة الشهرية — تُضاف لرسوم الميزات المختارة. */
|
||||
export const PLAN_MONTHLY_FEE: Record<TenantPlan, number> = {
|
||||
launch: 0,
|
||||
brand: 49,
|
||||
fleet: 149,
|
||||
sovereign: 0, // يُتفق عليه يدوياً
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
private readonly logger = new Logger('Catalog');
|
||||
|
||||
constructor(
|
||||
@InjectRepository(FeatureCatalog)
|
||||
private readonly repo: Repository<FeatureCatalog>,
|
||||
@InjectRepository(RideTypeCatalog)
|
||||
private readonly rideTypeRepo: Repository<RideTypeCatalog>,
|
||||
) {}
|
||||
|
||||
/** زراعة الكتالوج — آمنة للتشغيل المتكرر (upsert بالـkey). */
|
||||
async seed(): Promise<number> {
|
||||
let upserted = 0;
|
||||
for (const row of SEED_CATALOG) {
|
||||
const existing = await this.repo.findOne({ where: { key: row.key } });
|
||||
if (!existing) {
|
||||
await this.repo.save(this.repo.create(row));
|
||||
upserted++;
|
||||
}
|
||||
}
|
||||
if (upserted > 0) this.logger.log(`seeded ${upserted} catalog item(s)`);
|
||||
return upserted;
|
||||
}
|
||||
|
||||
/** كل عناصر الكتالوج (مع التصفية حسب الفئة). */
|
||||
async list(category?: 'core' | 'add_on' | 'bundle'): Promise<FeatureCatalog[]> {
|
||||
const where: any = {};
|
||||
if (category) where.category = category;
|
||||
return this.repo.find({ where, order: { sort_order: 'ASC' } });
|
||||
}
|
||||
|
||||
/** عنصر واحد بالـkey. */
|
||||
async findByKey(key: string): Promise<FeatureCatalog> {
|
||||
const item = await this.repo.findOne({ where: { key } });
|
||||
if (!item) throw new NotFoundException(`Feature "${key}" not found in catalog`);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** عنصر واحد بالـID. */
|
||||
async findById(id: string): Promise<FeatureCatalog> {
|
||||
const item = await this.repo.findOne({ where: { id } });
|
||||
if (!item) throw new NotFoundException(`Catalog item ${id} not found`);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** تحديث أسعار / وصف / حالة. */
|
||||
async update(
|
||||
id: string,
|
||||
patch: Partial<Pick<FeatureCatalog, 'monthly_price' | 'setup_fee' | 'currency' | 'description_ar' | 'name_ar' | 'active' | 'sort_order'>>,
|
||||
): Promise<FeatureCatalog> {
|
||||
const item = await this.findById(id);
|
||||
Object.assign(item, patch);
|
||||
return this.repo.save(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* حاسبة السعر لباقة + ميزات إضافية (docs/22 — N7).
|
||||
*
|
||||
* المُدخلات:
|
||||
* - `plan`: الباقة الأساسية
|
||||
* - `addOnKeys`: مفاتيح الميزات الإضافية المختارة
|
||||
* - `currency`: العملة المطلوبة (لتحويل الأسعار)
|
||||
*
|
||||
* المُخرجات:
|
||||
* - `monthly`: الرسوم الشهرية (رسوم الباقة + رسوم الميزات)
|
||||
* - `setup`: الرسوم التأسيسية (مرة واحدة)
|
||||
* - `items`: تفصيل كل بند
|
||||
*/
|
||||
async quote(
|
||||
plan: TenantPlan,
|
||||
addOnKeys: string[],
|
||||
currency = 'JOD',
|
||||
): Promise<{
|
||||
monthly: number;
|
||||
setup: number;
|
||||
currency: string;
|
||||
items: Array<{ key: string; name_ar: string; monthly: number; setup: number }>;
|
||||
planFee: number;
|
||||
}> {
|
||||
const items: Array<{ key: string; name_ar: string; monthly: number; setup: number }> = [];
|
||||
let totalMonthly = PLAN_MONTHLY_FEE[plan] ?? 0;
|
||||
let totalSetup = 0;
|
||||
|
||||
for (const key of addOnKeys) {
|
||||
const item = await this.findByKey(key);
|
||||
if (!item.active || item.category === 'core') continue;
|
||||
items.push({
|
||||
key: item.key,
|
||||
name_ar: item.name_ar,
|
||||
monthly: Number(item.monthly_price),
|
||||
setup: Number(item.setup_fee),
|
||||
});
|
||||
totalMonthly += Number(item.monthly_price);
|
||||
totalSetup += Number(item.setup_fee);
|
||||
}
|
||||
|
||||
return {
|
||||
monthly: Number(totalMonthly.toFixed(3)),
|
||||
setup: Number(totalSetup.toFixed(3)),
|
||||
currency,
|
||||
items,
|
||||
planFee: PLAN_MONTHLY_FEE[plan] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── كتالوج أنواع الرحلات العالمي (L2) ────────────────────────────────
|
||||
|
||||
/** زراعة الأنواع الأولية — آمنة للتشغيل المتكرر. */
|
||||
async seedRideTypes(): Promise<number> {
|
||||
let upserted = 0;
|
||||
for (const row of RIDE_TYPE_CATALOG_SEED) {
|
||||
const existing = await this.rideTypeRepo.findOne({ where: { code: row.code } });
|
||||
if (!existing) {
|
||||
await this.rideTypeRepo.save(this.rideTypeRepo.create(row));
|
||||
upserted++;
|
||||
}
|
||||
}
|
||||
if (upserted > 0) this.logger.log(`seeded ${upserted} ride type catalog item(s)`);
|
||||
return upserted;
|
||||
}
|
||||
|
||||
/** كل الأنواع المتاحة (مع إمكانية التصفية حسب vehicle_kind). */
|
||||
async listRideTypes(vehicleKind?: string): Promise<RideTypeCatalog[]> {
|
||||
const where: any = {};
|
||||
if (vehicleKind) where.vehicle_kind = vehicleKind;
|
||||
return this.rideTypeRepo.find({ where, order: { sort: 'ASC' } });
|
||||
}
|
||||
|
||||
/** نوع واحد بالـcode. */
|
||||
async findRideTypeByCode(code: string): Promise<RideTypeCatalog> {
|
||||
const item = await this.rideTypeRepo.findOne({ where: { code } });
|
||||
if (!item) throw new NotFoundException(`Ride type "${code}" not found in global catalog`);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** نوع واحد بالـID. */
|
||||
async findRideTypeById(id: string): Promise<RideTypeCatalog> {
|
||||
const item = await this.rideTypeRepo.findOne({ where: { id } });
|
||||
if (!item) throw new NotFoundException(`Ride type catalog item ${id} not found`);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** إنشاء نوع جديد في الكتالوج العالمي. */
|
||||
async createRideType(
|
||||
data: Partial<Pick<RideTypeCatalog, 'code' | 'name_ar' | 'name_en' | 'vehicle_kind' | 'women_only' | 'round_trip_supported' | 'requires_weight' | 'max_seats' | 'icon' | 'sort'>>,
|
||||
): Promise<RideTypeCatalog> {
|
||||
if (!data.code || !data.name_ar) {
|
||||
throw new BadRequestException('code and name_ar are required');
|
||||
}
|
||||
const existing = await this.rideTypeRepo.findOne({ where: { code: data.code } });
|
||||
if (existing) throw new BadRequestException(`Ride type "${data.code}" already exists`);
|
||||
return this.rideTypeRepo.save(this.rideTypeRepo.create(data));
|
||||
}
|
||||
|
||||
/** تحديث نوع في الكتالوج العالمي. */
|
||||
async updateRideType(
|
||||
id: string,
|
||||
patch: Partial<Pick<RideTypeCatalog, 'name_ar' | 'name_en' | 'vehicle_kind' | 'women_only' | 'round_trip_supported' | 'requires_weight' | 'max_seats' | 'icon' | 'sort' | 'active'>>,
|
||||
): Promise<RideTypeCatalog> {
|
||||
const item = await this.findRideTypeById(id);
|
||||
Object.assign(item, patch);
|
||||
return this.rideTypeRepo.save(item);
|
||||
}
|
||||
|
||||
/** تنشيط / تعطيل نوع. */
|
||||
async toggleRideType(id: string, active: boolean): Promise<RideTypeCatalog> {
|
||||
const item = await this.findRideTypeById(id);
|
||||
item.active = active;
|
||||
return this.rideTypeRepo.save(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* كتالوج الميزات القابلة للبيع (docs/22 — N6).
|
||||
*
|
||||
* كل ميزة لها سعر شهري + رسوم تأسيس. السوبر-أدمن يُعدّل الأسعار من اللوحة.
|
||||
* الكتالوج يُغذّي:
|
||||
* 1. `resolveEntitlements()` — ليعرف ما إذا كانت الميزة مشتراة
|
||||
* 2. مولّد العروض (N7) — لحساب تكلفة الباقة
|
||||
* 3. لوحة السوبر-거든 — لعرض الكتالوج وأسعاره
|
||||
*/
|
||||
@Entity('feature_catalog')
|
||||
@Index(['key'], { unique: true })
|
||||
export class FeatureCatalog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* المفتاح الفريد — يطابق التعريف في `features.ts`.
|
||||
* مثال: `dispatch`, `wallet`, `payments`.
|
||||
*/
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
key: string;
|
||||
|
||||
/** اسم الميزة بالعربية للعرض في لوحة السوبر-أدن. */
|
||||
@Column({ type: 'varchar' })
|
||||
name_ar: string;
|
||||
|
||||
/** اسم الميزة بالإنجليزية. */
|
||||
@Column({ type: 'varchar' })
|
||||
name_en: string;
|
||||
|
||||
/**
|
||||
* التصنيف: `core` (أساسية — مشمولة بالباقة)، `add_on` (إضافة مدفوعة)،
|
||||
* `bundle` (حزمة — مجموعة ميزات بسعر واحد).
|
||||
*/
|
||||
@Column({ type: 'varchar', default: 'add_on' })
|
||||
category: 'core' | 'add_on' | 'bundle';
|
||||
|
||||
/** السعر الشهري (بلا اشتراك = 0). */
|
||||
@Column({ type: 'numeric', precision: 10, scale: 3, default: 0 })
|
||||
monthly_price: number;
|
||||
|
||||
/** رسوم التأسيس (مرة واحدة). */
|
||||
@Column({ type: 'numeric', precision: 10, scale: 3, default: 0 })
|
||||
setup_fee: number;
|
||||
|
||||
/** العملة الافتراضية. */
|
||||
@Column({ type: 'varchar', default: 'JOD' })
|
||||
currency: string;
|
||||
|
||||
/** وصف الميزة بالعربية. */
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description_ar: string | null;
|
||||
|
||||
/** ترتيب العرض في اللوحة. */
|
||||
@Column({ type: 'int', default: 0 })
|
||||
sort_order: number;
|
||||
|
||||
/** الميزة فعّالة (يمكن تعطيلها بدون حذف). */
|
||||
@Column({ default: true })
|
||||
active: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* كتالوج أنواع الرحلات العالمي (L2).
|
||||
*
|
||||
* كل صف = نوع مركبة واحد يعرفه النظام ككل. المستأجرون يختارون
|
||||
* من هذه القائمة عند إنشاء `ride_type` خاصّ بهم (tripz_ride_types).
|
||||
*
|
||||
* الجدول: `tripz_ride_type_catalog`.
|
||||
*
|
||||
* النسخ الأولي يزرعه `CatalogService.onModuleInit` — 9 فئات موحّدة
|
||||
* للبلدان الثلاثة (سوري/مصري/أردني).
|
||||
*/
|
||||
@Entity('ride_type_catalog')
|
||||
@Index(['code'], { unique: true })
|
||||
export class RideTypeCatalog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
/** مفتاح فريد عالمي — economy | comfort | electric | van | ... */
|
||||
@Column()
|
||||
code: string;
|
||||
|
||||
@Column()
|
||||
name_ar: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name_en: string;
|
||||
|
||||
@Column({ default: 'car' })
|
||||
vehicle_kind: string; // car | van | scooter | bike
|
||||
|
||||
@Column({ default: false })
|
||||
women_only: boolean;
|
||||
|
||||
@Column({ default: true })
|
||||
round_trip_supported: boolean;
|
||||
|
||||
/** هل هذا النوع يحتاج وزن إلزامي (شحن)? */
|
||||
@Column({ default: false })
|
||||
requires_weight: boolean;
|
||||
|
||||
/** الحدّ الأقصى للمقاعد (4 لسيارة، 7 لفان، 1 لدراجة). */
|
||||
@Column({ type: 'int', default: 4 })
|
||||
max_seats: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
icon: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
sort: number;
|
||||
|
||||
@Column({ default: true })
|
||||
active: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* البيانات الأولية لكتالوج أنواع الرحلات العالمي (L2).
|
||||
*
|
||||
* تُزرع تلقائياً عند بدء التشغيل إن لم تكن موجودة. الكود يطابق
|
||||
* `SERVICE_CLASSES` في `default-tariffs.ts` — الفئة هنا = service_class
|
||||
* في التعرفة.
|
||||
*/
|
||||
export const RIDE_TYPE_CATALOG_SEED = [
|
||||
{
|
||||
code: 'economy',
|
||||
name_ar: 'اقتصادي',
|
||||
name_en: 'Economy',
|
||||
vehicle_kind: 'car',
|
||||
women_only: false,
|
||||
round_trip_supported: true,
|
||||
requires_weight: false,
|
||||
max_seats: 4,
|
||||
icon: 'sedan',
|
||||
sort: 0,
|
||||
},
|
||||
{
|
||||
code: 'comfort',
|
||||
name_ar: 'كومفورت',
|
||||
name_en: 'Comfort',
|
||||
vehicle_kind: 'car',
|
||||
women_only: false,
|
||||
round_trip_supported: true,
|
||||
requires_weight: false,
|
||||
max_seats: 4,
|
||||
icon: 'sedan-premium',
|
||||
sort: 1,
|
||||
},
|
||||
{
|
||||
code: 'electric',
|
||||
name_ar: 'كهربائي',
|
||||
name_en: 'Electric',
|
||||
vehicle_kind: 'car',
|
||||
women_only: false,
|
||||
round_trip_supported: true,
|
||||
requires_weight: false,
|
||||
max_seats: 4,
|
||||
icon: 'electric-car',
|
||||
sort: 2,
|
||||
},
|
||||
{
|
||||
code: 'lady',
|
||||
name_ar: 'سيدات',
|
||||
name_en: 'Ladies',
|
||||
vehicle_kind: 'car',
|
||||
women_only: true,
|
||||
round_trip_supported: true,
|
||||
requires_weight: false,
|
||||
max_seats: 4,
|
||||
icon: 'car-ladies',
|
||||
sort: 3,
|
||||
},
|
||||
{
|
||||
code: 'van',
|
||||
name_ar: 'فان',
|
||||
name_en: 'Van',
|
||||
vehicle_kind: 'van',
|
||||
women_only: false,
|
||||
round_trip_supported: true,
|
||||
requires_weight: false,
|
||||
max_seats: 7,
|
||||
icon: 'van',
|
||||
sort: 4,
|
||||
},
|
||||
{
|
||||
code: 'vip',
|
||||
name_ar: 'VIP',
|
||||
name_en: 'VIP',
|
||||
vehicle_kind: 'car',
|
||||
women_only: false,
|
||||
round_trip_supported: true,
|
||||
requires_weight: false,
|
||||
max_seats: 4,
|
||||
icon: 'car-vip',
|
||||
sort: 5,
|
||||
},
|
||||
{
|
||||
code: 'saver',
|
||||
name_ar: 'موفر',
|
||||
name_en: 'Saver',
|
||||
vehicle_kind: 'car',
|
||||
women_only: false,
|
||||
round_trip_supported: true,
|
||||
requires_weight: false,
|
||||
max_seats: 4,
|
||||
icon: 'car-saver',
|
||||
sort: 6,
|
||||
},
|
||||
{
|
||||
code: 'delivery',
|
||||
name_ar: 'توصيل',
|
||||
name_en: 'Delivery',
|
||||
vehicle_kind: 'bike',
|
||||
women_only: false,
|
||||
round_trip_supported: false,
|
||||
requires_weight: true,
|
||||
max_seats: 1,
|
||||
icon: 'delivery',
|
||||
sort: 7,
|
||||
},
|
||||
{
|
||||
code: 'fixed',
|
||||
name_ar: 'خط ثابت',
|
||||
name_en: 'Fixed Route',
|
||||
vehicle_kind: 'car',
|
||||
women_only: false,
|
||||
round_trip_supported: true,
|
||||
requires_weight: false,
|
||||
max_seats: 4,
|
||||
icon: 'route',
|
||||
sort: 8,
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Controller, Get, Post, Param, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
||||
import { TierCalculatorService } from './tier-calculator.service';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Driver } from './entities/driver.entity';
|
||||
|
||||
@ApiTags('admin-drivers')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard)
|
||||
@Roles('admin')
|
||||
@Controller('admin/drivers')
|
||||
@RequiresFeature('driver_tiers')
|
||||
export class AdminTierController {
|
||||
constructor(
|
||||
private readonly tierCalculator: TierCalculatorService,
|
||||
@InjectRepository(Driver)
|
||||
private readonly driverRepo: Repository<Driver>,
|
||||
) {}
|
||||
|
||||
@Get('tiers')
|
||||
async getTierSummary(@CurrentUser() user: AuthUser) {
|
||||
const drivers = await this.driverRepo.find({
|
||||
where: { tenant_id: user.tenantId, verification_status: 'approved' },
|
||||
});
|
||||
|
||||
const summary = {
|
||||
total: drivers.length,
|
||||
by_tier: {
|
||||
platinum: drivers.filter(d => d.tier === 'platinum').length,
|
||||
gold: drivers.filter(d => d.tier === 'gold').length,
|
||||
silver: drivers.filter(d => d.tier === 'silver').length,
|
||||
bronze: drivers.filter(d => d.tier === 'bronze').length,
|
||||
},
|
||||
};
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Get('tiers/:driverId')
|
||||
async getDriverTier(@Param('driverId') driverId: string) {
|
||||
return this.tierCalculator.getDriverTierInfo(driverId);
|
||||
}
|
||||
|
||||
@Post('tiers/recalculate/:driverId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async recalculateDriverTier(@Param('driverId') driverId: string) {
|
||||
return this.tierCalculator.updateDriverTier(driverId);
|
||||
}
|
||||
|
||||
@Post('tiers/recalculate-all')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async recalculateAllTiers() {
|
||||
return this.tierCalculator.batchUpdateAllDrivers();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Driver } from './entities/driver.entity';
|
||||
import { DriversService } from './drivers.service';
|
||||
import { DriversController } from './drivers.controller';
|
||||
import { AdminTierController } from './admin-tier.controller';
|
||||
import { TierCalculatorService } from './tier-calculator.service';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { LocationsModule } from '../locations/locations.module';
|
||||
import { CreditModule } from '../credit/credit.module';
|
||||
@@ -14,8 +16,8 @@ import { CreditModule } from '../credit/credit.module';
|
||||
LocationsModule,
|
||||
forwardRef(() => CreditModule),
|
||||
],
|
||||
controllers: [DriversController],
|
||||
providers: [DriversService],
|
||||
exports: [DriversService],
|
||||
controllers: [DriversController, AdminTierController],
|
||||
providers: [DriversService, TierCalculatorService],
|
||||
exports: [DriversService, TierCalculatorService],
|
||||
})
|
||||
export class DriversModule {}
|
||||
|
||||
@@ -119,6 +119,15 @@ export class DriversService {
|
||||
);
|
||||
}
|
||||
|
||||
async incrementTripCount(tenantId: string, driverId: string): Promise<void> {
|
||||
await this.repo
|
||||
.createQueryBuilder()
|
||||
.update(Driver)
|
||||
.set({ total_trips: () => 'total_trips + 1' })
|
||||
.where('tenant_id = :tenantId AND id = :driverId', { tenantId, driverId })
|
||||
.execute();
|
||||
}
|
||||
|
||||
async approve(tenantId: string, driverId: string): Promise<Driver> {
|
||||
const driver = await this.findById(tenantId, driverId);
|
||||
if (!driver) throw new NotFoundException('Driver not found');
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
||||
|
||||
export type VerificationStatus = 'pending' | 'approved' | 'rejected';
|
||||
export type DriverTier = 'bronze' | 'silver' | 'gold' | 'platinum';
|
||||
|
||||
/**
|
||||
* السائق = مستخدم بدور driver + بيانات مركبة ووثائق. الجدول: tripz_drivers.
|
||||
@@ -54,6 +55,18 @@ export class Driver {
|
||||
@Column({ type: 'numeric', precision: 3, scale: 2, default: 5 })
|
||||
rating: number;
|
||||
|
||||
@Column({ type: 'varchar', default: 'bronze' })
|
||||
tier: DriverTier;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
tier_score: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
total_trips: number;
|
||||
|
||||
@Column({ type: 'numeric', precision: 5, scale: 2, default: 100 })
|
||||
acceptance_rate: number;
|
||||
|
||||
// ---- لقطة الموقع (مكافئ `car_locations` عند سيرو) ----
|
||||
// الموقع الحيّ يعيش في Redis؛ هذه الأعمدة **يكتبها الـworker دورياً فقط**،
|
||||
// لا الطلب. لا تقرأها للحيّ — استخدم DriverLocationService (docs/17 — H1/H3).
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Driver, DriverTier } from './entities/driver.entity';
|
||||
|
||||
interface TierThresholds {
|
||||
platinum_min_score: number;
|
||||
gold_min_score: number;
|
||||
silver_min_score: number;
|
||||
bronze_min_score: number;
|
||||
min_trips: number;
|
||||
min_acceptance: number;
|
||||
min_rating: number;
|
||||
}
|
||||
|
||||
const DEFAULT_THRESHOLDS: TierThresholds = {
|
||||
platinum_min_score: 1000,
|
||||
gold_min_score: 800,
|
||||
silver_min_score: 400,
|
||||
bronze_min_score: 0,
|
||||
min_trips: 10,
|
||||
min_acceptance: 80,
|
||||
min_rating: 4.5,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TierCalculatorService {
|
||||
private readonly logger = new Logger(TierCalculatorService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Driver)
|
||||
private readonly driverRepo: Repository<Driver>,
|
||||
) {}
|
||||
|
||||
async calculateScore(driverId: string): Promise<number> {
|
||||
const driver = await this.driverRepo.findOne({ where: { id: driverId } });
|
||||
if (!driver) return 0;
|
||||
|
||||
const ratingScore = (driver.rating / 5) * 400;
|
||||
const tripsScore = Math.min(driver.total_trips * 10, 300);
|
||||
const acceptanceScore = (driver.acceptance_rate / 100) * 300;
|
||||
|
||||
return Math.round(ratingScore + tripsScore + acceptanceScore);
|
||||
}
|
||||
|
||||
async calculateTier(score: number): Promise<DriverTier> {
|
||||
if (score >= DEFAULT_THRESHOLDS.platinum_min_score) return 'platinum';
|
||||
if (score >= DEFAULT_THRESHOLDS.gold_min_score) return 'gold';
|
||||
if (score >= DEFAULT_THRESHOLDS.silver_min_score) return 'silver';
|
||||
return 'bronze';
|
||||
}
|
||||
|
||||
async updateDriverTier(driverId: string): Promise<{ tier: DriverTier; score: number }> {
|
||||
const score = await this.calculateScore(driverId);
|
||||
const tier = await this.calculateTier(score);
|
||||
|
||||
await this.driverRepo.update(driverId, { tier, tier_score: score });
|
||||
this.logger.log(`Driver ${driverId} updated to ${tier} (score: ${score})`);
|
||||
|
||||
return { tier, score };
|
||||
}
|
||||
|
||||
async batchUpdateAllDrivers(): Promise<{ updated: number }> {
|
||||
const drivers = await this.driverRepo.find({
|
||||
where: { verification_status: 'approved' },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
let updated = 0;
|
||||
for (const driver of drivers) {
|
||||
await this.updateDriverTier(driver.id);
|
||||
updated++;
|
||||
}
|
||||
|
||||
this.logger.log(`Batch updated ${updated} driver tiers`);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async getDriverTierInfo(driverId: string): Promise<{
|
||||
tier: DriverTier;
|
||||
score: number;
|
||||
total_trips: number;
|
||||
rating: number;
|
||||
acceptance_rate: number;
|
||||
}> {
|
||||
const driver = await this.driverRepo.findOne({ where: { id: driverId } });
|
||||
if (!driver) throw new Error('Driver not found');
|
||||
|
||||
return {
|
||||
tier: driver.tier,
|
||||
score: driver.tier_score,
|
||||
total_trips: driver.total_trips,
|
||||
rating: driver.rating,
|
||||
acceptance_rate: driver.acceptance_rate,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { GeofenceService } from './geofence.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* إدارة المناطق الجغرافية — أدمن المستأجر (M3).
|
||||
*
|
||||
* خلف `JwtAuthGuard + RolesGuard` — فقط admin/dispatcher يمكنه إدارة المناطق.
|
||||
*/
|
||||
@ApiTags('geofence-admin')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Controller('admin/geofence')
|
||||
export class GeofenceAdminController {
|
||||
constructor(private readonly geofence: GeofenceService) {}
|
||||
|
||||
/** كل المناطق. */
|
||||
@Get('zones')
|
||||
listZones(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('active') active?: string,
|
||||
) {
|
||||
return this.geofence.listZones(user.tenantId, active === 'true');
|
||||
}
|
||||
|
||||
/** منطقة واحدة. */
|
||||
@Get('zones/:id')
|
||||
getZone(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.geofence.findZoneById(user.tenantId, id);
|
||||
}
|
||||
|
||||
/** إنشاء منطقة. */
|
||||
@Post('zones')
|
||||
createZone(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
radius_meters: number;
|
||||
priority?: number;
|
||||
pricing_multiplier?: number;
|
||||
campaign_title?: string;
|
||||
campaign_body?: string;
|
||||
},
|
||||
) {
|
||||
return this.geofence.createZone(user.tenantId, body);
|
||||
}
|
||||
|
||||
/** تحديث منطقة. */
|
||||
@Patch('zones/:id')
|
||||
updateZone(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: {
|
||||
name?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
radius_meters?: number;
|
||||
priority?: number;
|
||||
is_active?: boolean;
|
||||
pricing_multiplier?: number;
|
||||
campaign_title?: string;
|
||||
campaign_body?: string;
|
||||
},
|
||||
) {
|
||||
return this.geofence.updateZone(user.tenantId, id, body);
|
||||
}
|
||||
|
||||
/** حذف منطقة. */
|
||||
@Delete('zones/:id')
|
||||
deleteZone(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.geofence.deleteZone(user.tenantId, id);
|
||||
}
|
||||
|
||||
/** تنشيط / تعطيل. */
|
||||
@Patch('zones/:id/toggle')
|
||||
toggleZone(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { active: boolean },
|
||||
) {
|
||||
return this.geofence.toggleZone(user.tenantId, id, body.active);
|
||||
}
|
||||
|
||||
/** سجل الحملات. */
|
||||
@Get('campaigns')
|
||||
campaignHistory(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.geofence.campaignHistory(
|
||||
user.tenantId,
|
||||
limit ? parseInt(limit, 10) : 50,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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 { GeofenceAdminController } from './geofence-admin.controller';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
@@ -12,7 +13,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
NotificationsModule,
|
||||
],
|
||||
providers: [GeofenceService],
|
||||
controllers: [GeofenceController],
|
||||
controllers: [GeofenceController, GeofenceAdminController],
|
||||
exports: [GeofenceService],
|
||||
})
|
||||
export class GeofenceModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { GeofenceZone } from './entities/geofence-zone.entity';
|
||||
@@ -106,4 +106,75 @@ export class GeofenceService {
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// ─── إدارة المناطق — أدمن المستأجر (M3) ────────────────────────────
|
||||
|
||||
/** كل المناطق (مع إمكانية التصفية حسب الحالة). */
|
||||
async listZones(tenantId: string, activeOnly = false): Promise<GeofenceZone[]> {
|
||||
const where: any = { tenant_id: tenantId };
|
||||
if (activeOnly) where.is_active = true;
|
||||
return this.zonesRepo.find({ where, order: { priority: 'DESC', name: 'ASC' } });
|
||||
}
|
||||
|
||||
/** منطقة واحدة. */
|
||||
async findZoneById(tenantId: string, id: string): Promise<GeofenceZone> {
|
||||
const zone = await this.zonesRepo.findOne({ where: { tenant_id: tenantId, id } });
|
||||
if (!zone) throw new NotFoundException(`Geofence zone ${id} not found`);
|
||||
return zone;
|
||||
}
|
||||
|
||||
/** إنشاء منطقة جديدة. */
|
||||
async createZone(
|
||||
tenantId: string,
|
||||
data: Partial<Pick<GeofenceZone, 'name' | 'latitude' | 'longitude' | 'radius_meters' | 'priority' | 'pricing_multiplier' | 'campaign_title' | 'campaign_body'>>,
|
||||
): Promise<GeofenceZone> {
|
||||
if (!data.name) throw new BadRequestException('name is required');
|
||||
if (data.latitude == null || data.longitude == null) {
|
||||
throw new BadRequestException('latitude and longitude are required');
|
||||
}
|
||||
if (data.radius_meters == null || data.radius_meters <= 0) {
|
||||
throw new BadRequestException('radius_meters must be positive');
|
||||
}
|
||||
return this.zonesRepo.save(this.zonesRepo.create({ tenant_id: tenantId, ...data }));
|
||||
}
|
||||
|
||||
/** تحديث منطقة. */
|
||||
async updateZone(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
patch: Partial<Pick<GeofenceZone, 'name' | 'latitude' | 'longitude' | 'radius_meters' | 'priority' | 'is_active' | 'pricing_multiplier' | 'campaign_title' | 'campaign_body'>>,
|
||||
): Promise<GeofenceZone> {
|
||||
const zone = await this.findZoneById(tenantId, id);
|
||||
Object.assign(zone, patch);
|
||||
return this.zonesRepo.save(zone);
|
||||
}
|
||||
|
||||
/** حذف منطقة. */
|
||||
async deleteZone(tenantId: string, id: string): Promise<void> {
|
||||
const zone = await this.findZoneById(tenantId, id);
|
||||
await this.zonesRepo.remove(zone);
|
||||
}
|
||||
|
||||
/** تنشيط / تعطيل منطقة. */
|
||||
async toggleZone(tenantId: string, id: string, active: boolean): Promise<GeofenceZone> {
|
||||
const zone = await this.findZoneById(tenantId, id);
|
||||
zone.is_active = active;
|
||||
return this.zonesRepo.save(zone);
|
||||
}
|
||||
|
||||
/** إحصائيات الحملات — آخر N إرسالاً لمناطق هذا المستأجر. */
|
||||
async campaignHistory(tenantId: string, limit = 50) {
|
||||
return this.campaignLogRepo
|
||||
.createQueryBuilder('log')
|
||||
.innerJoin(GeofenceZone, 'z', 'z.id = log.zone_id AND z.tenant_id = :tenantId', { tenantId })
|
||||
.select('log.id', 'id')
|
||||
.addSelect('log.user_id', 'user_id')
|
||||
.addSelect('log.zone_id', 'zone_id')
|
||||
.addSelect('z.name', 'zone_name')
|
||||
.addSelect('log.user_type', 'user_type')
|
||||
.addSelect('log.sent_at', 'sent_at')
|
||||
.orderBy('log.sent_at', 'DESC')
|
||||
.limit(limit)
|
||||
.getRawMany();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { HeatmapService } from './heatmap.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
||||
|
||||
/**
|
||||
* الخريطة الحرارية — أدمن المستأجر (O1).
|
||||
*
|
||||
* تُظهر توزيع السائقين الأحياء على الخريطة.
|
||||
*/
|
||||
@ApiTags('heatmap')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard)
|
||||
@Roles('admin', 'dispatcher')
|
||||
@Controller('admin/heatmap')
|
||||
@RequiresFeature('market_intel')
|
||||
export class HeatmapController {
|
||||
constructor(private readonly heatmap: HeatmapService) {}
|
||||
|
||||
/** الخريطة الحرارية — آخر N دقيقة. */
|
||||
@Get()
|
||||
getHeatmap(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('minutes') minutes?: string,
|
||||
@Query('minLat') minLat?: string,
|
||||
@Query('maxLat') maxLat?: string,
|
||||
@Query('minLng') minLng?: string,
|
||||
@Query('maxLng') maxLng?: string,
|
||||
) {
|
||||
const m = Number(minutes);
|
||||
const duration = Number.isFinite(m) && m > 0 ? Math.min(m, 1440) : 15;
|
||||
|
||||
const bounds =
|
||||
minLat && maxLat && minLng && maxLng
|
||||
? {
|
||||
minLat: parseFloat(minLat),
|
||||
maxLat: parseFloat(maxLat),
|
||||
minLng: parseFloat(minLng),
|
||||
maxLng: parseFloat(maxLng),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return this.heatmap.generate(user.tenantId, duration, bounds);
|
||||
}
|
||||
|
||||
/** مسح الكاش (للتطوير أو بعد تحديث كبير). */
|
||||
@Get('invalidate')
|
||||
invalidate(@CurrentUser() user: AuthUser) {
|
||||
return this.heatmap.invalidate(user.tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Repository, MoreThan } from 'typeorm';
|
||||
import Redis from 'ioredis';
|
||||
import { DriverTrack } from './entities/driver-track.entity';
|
||||
import { REDIS } from '../../common/redis/redis.module';
|
||||
|
||||
/**
|
||||
* خدمة الخريطة الحرارية (O1).
|
||||
*
|
||||
* تقرأ نقاط المسار من `driver_tracks` وتجمّعها في خلايا شبكية
|
||||
* (grid buckets) — كل خلية 0.01° × 0.01° ≈ 1.1km × 1.1km.
|
||||
*
|
||||
* النتائج تُكاش في Redis لمدة 5 دقائق — لا إعادة حساب في كل طلب.
|
||||
*
|
||||
* الاستخدام:
|
||||
* - لوحة الأدمن: رؤية توزيع السائقين الأحياء حياً
|
||||
* - استخبار السوق: تغذية `cron_surge_opportunity`
|
||||
* - الخريطة التنبؤية: أساس لتاريخ الطلب
|
||||
*/
|
||||
@Injectable()
|
||||
export class HeatmapService {
|
||||
private readonly logger = new Logger('Heatmap');
|
||||
|
||||
/** حجم الخلية بالدرجات — 0.01° ≈ 1.1km. */
|
||||
private static readonly CELL_SIZE = 0.01;
|
||||
/** مدة الكاش بالثواني. */
|
||||
private static readonly CACHE_TTL = 300; // 5 دقائق
|
||||
/** مفتاح الكاش. */
|
||||
private static readonly CACHE_PREFIX = 'heatmap:';
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DriverTrack)
|
||||
private readonly tracks: Repository<DriverTrack>,
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* توليد الخريطة الحرارية لمستأجر.
|
||||
*
|
||||
* @param tenantId معرّف المستأجر
|
||||
* @param minutes آخر N دقيقة (افتراضي 15)
|
||||
* @param bounds حدود الخريطة (اختياري — لتحديد منطقة محددة)
|
||||
* @returns مصفوفة خلايا: `{ cell: "lat,lng", count, lat, lng }`
|
||||
*/
|
||||
async generate(
|
||||
tenantId: string,
|
||||
minutes = 15,
|
||||
bounds?: { minLat: number; maxLat: number; minLng: number; maxLng: number },
|
||||
): Promise<Array<{ cell: string; count: number; lat: number; lng: number }>> {
|
||||
// التحقق من الكاش أولاً
|
||||
const cacheKey = `${HeatmapService.CACHE_PREFIX}${tenantId}:${minutes}`;
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached);
|
||||
} catch { /* كسر كاش — نحسب من جديد */ }
|
||||
}
|
||||
|
||||
const since = new Date(Date.now() - minutes * 60_000);
|
||||
|
||||
// استعلام نقاط المسار — فقط السائقين النشطين
|
||||
const query = this.tracks
|
||||
.createQueryBuilder('t')
|
||||
.select('t.lat', 'lat')
|
||||
.addSelect('t.lng', 'lng')
|
||||
.where('t.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('t.recorded_at > :since', { since });
|
||||
|
||||
if (bounds) {
|
||||
query
|
||||
.andWhere('t.lat >= :minLat', { minLat: bounds.minLat })
|
||||
.andWhere('t.lat <= :maxLat', { maxLat: bounds.maxLat })
|
||||
.andWhere('t.lng >= :minLng', { minLng: bounds.minLng })
|
||||
.andWhere('t.lng <= :maxLng', { maxLng: bounds.maxLng });
|
||||
}
|
||||
|
||||
const points = await query.getRawMany<{ lat: number; lng: number }>();
|
||||
|
||||
// تجميع في خلايا شبكية
|
||||
const cells = new Map<string, { count: number; lat: number; lng: number }>();
|
||||
|
||||
for (const p of points) {
|
||||
const cellLat = Math.floor(Number(p.lat) / HeatmapService.CELL_SIZE) * HeatmapService.CELL_SIZE;
|
||||
const cellLng = Math.floor(Number(p.lng) / HeatmapService.CELL_SIZE) * HeatmapService.CELL_SIZE;
|
||||
const key = `${cellLat.toFixed(4)},${cellLng.toFixed(4)}`;
|
||||
|
||||
const existing = cells.get(key);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
cells.set(key, {
|
||||
count: 1,
|
||||
lat: cellLat + HeatmapService.CELL_SIZE / 2,
|
||||
lng: cellLng + HeatmapService.CELL_SIZE / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = Array.from(cells.entries()).map(([cell, data]) => ({
|
||||
cell,
|
||||
...data,
|
||||
}));
|
||||
|
||||
// كاش النتيجة
|
||||
await this.redis.setex(cacheKey, HeatmapService.CACHE_TTL, JSON.stringify(result));
|
||||
|
||||
if (result.length > 0) {
|
||||
this.logger.log(`heatmap ${tenantId}: ${points.length} points → ${result.length} cells (${minutes}min)`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* إ invalidated الكاش يدوياً (عند تحديث البيانات أو للتطوير).
|
||||
*/
|
||||
async invalidate(tenantId: string): Promise<void> {
|
||||
const pattern = `${HeatmapService.CACHE_PREFIX}${tenantId}:*`;
|
||||
const keys = await this.redis.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
await this.redis.del(...keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,19 @@ import { Driver } from '../drivers/entities/driver.entity';
|
||||
import { DriverTrack } from './entities/driver-track.entity';
|
||||
import { DriverLocationService } from './driver-location.service';
|
||||
import { LocationFlusherService } from './location-flusher.service';
|
||||
import { HeatmapService } from './heatmap.service';
|
||||
import { HeatmapController } from './heatmap.controller';
|
||||
import { MatchingModule } from '../matching/matching.module';
|
||||
import { GeofenceModule } from '../geofence/geofence.module';
|
||||
|
||||
/**
|
||||
* وحدة مستقلة عمداً: تستورد MatchingModule، وتستوردها DriversModule و
|
||||
* TripsModule — لو عاشت داخل drivers لصارت دورة استيراد مع matching.
|
||||
* وحدة المستخدمات — موقع + خرائط حرارية.
|
||||
* تستورد MatchingModule وGeofenceModule، وتستوردها DriversModule وTripsModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Driver, DriverTrack]), MatchingModule, GeofenceModule],
|
||||
providers: [DriverLocationService, LocationFlusherService],
|
||||
exports: [DriverLocationService, LocationFlusherService],
|
||||
providers: [DriverLocationService, LocationFlusherService, HeatmapService],
|
||||
controllers: [HeatmapController],
|
||||
exports: [DriverLocationService, LocationFlusherService, HeatmapService],
|
||||
})
|
||||
export class LocationsModule {}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
export type CampaignType = 'push_broadcast' | 're_engagement' | 'promo' | 'auto_marketing';
|
||||
export type CampaignStatus = 'draft' | 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
|
||||
@Entity('marketing_campaigns')
|
||||
export class Campaign {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
type: CampaignType;
|
||||
|
||||
@Column({ type: 'varchar', default: 'draft' })
|
||||
status: CampaignStatus;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
body: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
data: Record<string, any>;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
targeting: Record<string, any>;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
sent_count: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
open_count: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
click_count: number;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
scheduled_at: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
completed_at: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
||||
import { MarketingService } from './marketing.service';
|
||||
import { Campaign, CampaignType } from './entities/campaign.entity';
|
||||
|
||||
@ApiTags('marketing')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard)
|
||||
@Roles('admin')
|
||||
@Controller('admin/marketing')
|
||||
@RequiresFeature('marketing_engine')
|
||||
export class MarketingController {
|
||||
constructor(private readonly marketingService: MarketingService) {}
|
||||
|
||||
@Get('campaigns')
|
||||
async listCampaigns(@CurrentUser() user: AuthUser) {
|
||||
return this.marketingService.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('campaigns/:id')
|
||||
async getCampaign(@Param('id') id: string) {
|
||||
return this.marketingService.findOne(id);
|
||||
}
|
||||
|
||||
@Post('campaigns')
|
||||
async createCampaign(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: {
|
||||
name: string;
|
||||
type: CampaignType;
|
||||
title?: string;
|
||||
body?: string;
|
||||
data?: Record<string, any>;
|
||||
targeting?: Record<string, any>;
|
||||
scheduled_at?: string;
|
||||
},
|
||||
) {
|
||||
return this.marketingService.create({
|
||||
tenant_id: user.tenantId,
|
||||
...body,
|
||||
scheduled_at: body.scheduled_at ? new Date(body.scheduled_at) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('campaigns/:id/status')
|
||||
async updateStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: 'draft' | 'scheduled' | 'cancelled' },
|
||||
) {
|
||||
return this.marketingService.updateStatus(id, body.status);
|
||||
}
|
||||
|
||||
@Post('campaigns/:id/run')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async runCampaign(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { userIds: string[] },
|
||||
) {
|
||||
return this.marketingService.runCampaign(id, body.userIds);
|
||||
}
|
||||
|
||||
@Get('campaigns/:id/stats')
|
||||
async getCampaignStats(@Param('id') id: string) {
|
||||
return this.marketingService.getCampaignStats(id);
|
||||
}
|
||||
|
||||
@Post('campaigns/:id/events')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async recordEvent(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { event: 'open' | 'click' },
|
||||
) {
|
||||
await this.marketingService.recordEvent(id, body.event);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post('generate-content')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async generateContent(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: { prompt: string },
|
||||
) {
|
||||
return this.marketingService.generateContent(user.tenantId, body.prompt);
|
||||
}
|
||||
|
||||
@Delete('campaigns/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteCampaign(@Param('id') id: string) {
|
||||
await this.marketingService.deleteCampaign(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Campaign } from './entities/campaign.entity';
|
||||
import { MarketingService } from './marketing.service';
|
||||
import { MarketingController } from './marketing.controller';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { GeminiModule } from '../../integrations/gemini/gemini.module';
|
||||
import { Trip } from '../trips/entities/trip.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Campaign, Trip]),
|
||||
NotificationsModule,
|
||||
GeminiModule,
|
||||
],
|
||||
controllers: [MarketingController],
|
||||
providers: [MarketingService],
|
||||
exports: [MarketingService],
|
||||
})
|
||||
export class MarketingModule {}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThanOrEqual, Not, In } from 'typeorm';
|
||||
import { Campaign, CampaignStatus, CampaignType } from './entities/campaign.entity';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { GeminiService } from '../../integrations/gemini/gemini.service';
|
||||
import { Trip } from '../trips/entities/trip.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MarketingService {
|
||||
private readonly logger = new Logger(MarketingService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Campaign)
|
||||
private readonly campaignRepo: Repository<Campaign>,
|
||||
@InjectRepository(Trip)
|
||||
private readonly tripsRepo: Repository<Trip>,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly gemini: GeminiService,
|
||||
) {}
|
||||
|
||||
async create(data: Partial<Campaign>): Promise<Campaign> {
|
||||
const campaign = this.campaignRepo.create(data);
|
||||
return this.campaignRepo.save(campaign);
|
||||
}
|
||||
|
||||
async findAll(tenantId: string): Promise<Campaign[]> {
|
||||
return this.campaignRepo.find({
|
||||
where: { tenant_id: tenantId },
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Campaign | null> {
|
||||
return this.campaignRepo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: CampaignStatus): Promise<Campaign> {
|
||||
await this.campaignRepo.update(id, { status });
|
||||
return this.campaignRepo.findOneOrFail({ where: { id } });
|
||||
}
|
||||
|
||||
async generateContent(tenantId: string, prompt: string): Promise<{ title: string; body: string }> {
|
||||
if (!this.gemini.enabled) {
|
||||
return { title: 'Ride with us!', body: 'Get 20% off your next trip.' };
|
||||
}
|
||||
|
||||
const systemPrompt = [
|
||||
'أنت كاتب محتوى تسويقي لتطبيق رحلات (ride-hailing).',
|
||||
'اكتب رسالة إشعار جذابة وقصيرة (سطرين-ثلاثة).',
|
||||
'الرد بالعربية.',
|
||||
'',
|
||||
'المطلوب:',
|
||||
prompt,
|
||||
'',
|
||||
'أعِد JSON فقط: {"title": "العنوان", "body": "النص"}',
|
||||
].join('\n');
|
||||
|
||||
const result = await this.generateJson(systemPrompt);
|
||||
return { title: result.title || 'Ride with us!', body: result.body || 'Get 20% off your next trip.' };
|
||||
}
|
||||
|
||||
private async generateJson(prompt: string): Promise<any> {
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=${process.env.GEMINI_API_KEY}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
generationConfig: { temperature: 0.7, responseMimeType: 'application/json' },
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!res.ok) return { title: 'Ride with us!', body: 'Get 20% off your next trip.' };
|
||||
const data: any = await res.json();
|
||||
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}';
|
||||
try {
|
||||
return JSON.parse(text.replace(/^```json\s*|\s*```$/g, '').trim());
|
||||
} catch {
|
||||
return { title: 'Ride with us!', body: 'Get 20% off your next trip.' };
|
||||
}
|
||||
}
|
||||
|
||||
async runCampaign(campaignId: string, userIds: string[]): Promise<{ sent: number }> {
|
||||
const campaign = await this.campaignRepo.findOne({ where: { id: campaignId } });
|
||||
if (!campaign) throw new Error('Campaign not found');
|
||||
|
||||
await this.campaignRepo.update(campaignId, { status: 'running' });
|
||||
|
||||
let sent = 0;
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await this.notifications.sendToUser(
|
||||
campaign.tenant_id,
|
||||
userId,
|
||||
campaign.title || 'Notification',
|
||||
campaign.body || '',
|
||||
{ campaign_id: campaignId, ...campaign.data },
|
||||
);
|
||||
sent++;
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to send to ${userId}: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.campaignRepo.update(campaignId, {
|
||||
status: 'completed',
|
||||
sent_count: sent,
|
||||
completed_at: new Date(),
|
||||
});
|
||||
|
||||
return { sent };
|
||||
}
|
||||
|
||||
async getCampaignStats(campaignId: string): Promise<{
|
||||
sent: number;
|
||||
open_rate: number;
|
||||
click_rate: number;
|
||||
}> {
|
||||
const campaign = await this.campaignRepo.findOne({ where: { id: campaignId } });
|
||||
if (!campaign) throw new Error('Campaign not found');
|
||||
|
||||
const openRate = campaign.sent_count > 0 ? (campaign.open_count / campaign.sent_count) * 100 : 0;
|
||||
const clickRate = campaign.sent_count > 0 ? (campaign.click_count / campaign.sent_count) * 100 : 0;
|
||||
|
||||
return {
|
||||
sent: campaign.sent_count,
|
||||
open_rate: Math.round(openRate * 100) / 100,
|
||||
click_rate: Math.round(clickRate * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
async recordEvent(campaignId: string, event: 'open' | 'click'): Promise<void> {
|
||||
if (event === 'open') {
|
||||
await this.campaignRepo.increment({ id: campaignId }, 'open_count', 1);
|
||||
} else if (event === 'click') {
|
||||
await this.campaignRepo.increment({ id: campaignId }, 'click_count', 1);
|
||||
}
|
||||
}
|
||||
|
||||
async findDueReEngagement(tenantId: string, inactiveDays = 3): Promise<Campaign[]> {
|
||||
const where: any = {
|
||||
type: 're_engagement',
|
||||
status: 'scheduled',
|
||||
scheduled_at: LessThanOrEqual(new Date()),
|
||||
};
|
||||
if (tenantId !== '*') {
|
||||
where.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
return this.campaignRepo.find({ where });
|
||||
}
|
||||
|
||||
async deleteCampaign(id: string): Promise<void> {
|
||||
await this.campaignRepo.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* جلب معرّفات المستخدمين الذين لم ي新开وا رحلة منذ N يوم.
|
||||
* يُستخدم لاستهداف حملات إعادة التفاعل فعلياً بدل القائمة الفارغة.
|
||||
*/
|
||||
async findInactiveUserIds(tenantId: string, inactiveDays = 3): Promise<string[]> {
|
||||
const cutoff = new Date(Date.now() - inactiveDays * 86_400_000);
|
||||
|
||||
// المستخدمون الذين لديهم رحلة أحدث من cutoff
|
||||
const activeSubquery = this.tripsRepo
|
||||
.createQueryBuilder('t')
|
||||
.select('t.rider_id')
|
||||
.where('t.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('t.requested_at > :cutoff', { cutoff });
|
||||
|
||||
// جلب كل المستخدمين الفريددين في هذا المستأجر
|
||||
const allRiders = await this.tripsRepo
|
||||
.createQueryBuilder('t')
|
||||
.select('DISTINCT t.rider_id', 'rider_id')
|
||||
.where('t.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('t.rider_id IS NOT NULL')
|
||||
.getRawMany<{ rider_id: string }>();
|
||||
|
||||
const allIds = allRiders.map((r) => r.rider_id).filter(Boolean);
|
||||
if (allIds.length === 0) return [];
|
||||
|
||||
// استبعاد من لديهم رحلة حديثة
|
||||
const activeIds = await activeSubquery.getRawMany<{ rider_id: string }>();
|
||||
const activeSet = new Set(activeIds.map((r) => r.rider_id));
|
||||
|
||||
return allIds.filter((id) => !activeSet.has(id));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MatchingService } from './matching.service';
|
||||
import { Driver } from '../drivers/entities/driver.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Driver])],
|
||||
providers: [MatchingService],
|
||||
exports: [MatchingService],
|
||||
})
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS } from '../../common/redis/redis.module';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Driver, DriverTier } from '../drivers/entities/driver.entity';
|
||||
|
||||
export interface NearbyDriver {
|
||||
driverId: string;
|
||||
distanceKm: number;
|
||||
tier?: DriverTier;
|
||||
tierScore?: number;
|
||||
}
|
||||
|
||||
/** توفّر السائق — يحدّد الفهرس الذي يعيش فيه (docs/17 — H5). */
|
||||
export type DriverAvailability = 'available' | 'busy' | 'off';
|
||||
|
||||
/**
|
||||
* المطابقة الجغرافية عبر Redis GEO (docs/09). مفتاح لكل
|
||||
* (مستأجر × فئة خدمة × توفّر): geo:drivers:{tenantId}:{class}:{available|busy}
|
||||
* (ببادئة tripz: تلقائياً من ioredis).
|
||||
*
|
||||
* فصل available عن busy (نمط سيرو): البحث يمسح المتاحين فقط بدل أن يجلب
|
||||
* الجميع ثم يصفّي المشغولين.
|
||||
*/
|
||||
const TIER_PRIORITY: Record<DriverTier, number> = {
|
||||
platinum: 4,
|
||||
gold: 3,
|
||||
silver: 2,
|
||||
bronze: 1,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MatchingService {
|
||||
constructor(@Inject(REDIS) private readonly redis: Redis) {}
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
@InjectRepository(Driver)
|
||||
private readonly driverRepo: Repository<Driver>,
|
||||
) { }
|
||||
|
||||
private key(tenantId: string, serviceClass: string, status: 'available' | 'busy'): string {
|
||||
return `geo:drivers:${tenantId}:${serviceClass}:${status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* يضع السائق في الفهرس الموافق لحالته ويزيله من الآخر — عملية واحدة
|
||||
* تمنع بقاءه في الفهرسين معاً.
|
||||
*/
|
||||
async setPosition(
|
||||
tenantId: string,
|
||||
serviceClass: string,
|
||||
@@ -59,13 +62,12 @@ export class MatchingService {
|
||||
.exec();
|
||||
}
|
||||
|
||||
/** أقرب السائقين **المتاحين** من نفس فئة الخدمة ضمن نصف قطر (كم). */
|
||||
async findNearby(
|
||||
tenantId: string,
|
||||
serviceClass: string,
|
||||
lat: number,
|
||||
lng: number,
|
||||
radiusKm = 5,
|
||||
radiusKm = 3,
|
||||
count = 10,
|
||||
): Promise<NearbyDriver[]> {
|
||||
const res = (await this.redis.call(
|
||||
@@ -79,14 +81,35 @@ export class MatchingService {
|
||||
'km',
|
||||
'ASC',
|
||||
'COUNT',
|
||||
String(count),
|
||||
String(count * 2),
|
||||
'WITHDIST',
|
||||
)) as [string, string][];
|
||||
|
||||
if (!Array.isArray(res)) return [];
|
||||
return res.map(([driverId, dist]) => ({
|
||||
if (!Array.isArray(res) || res.length === 0) return [];
|
||||
|
||||
const driverIds = res.map(([id]) => id);
|
||||
const drivers = await this.driverRepo.find({
|
||||
where: driverIds.map(id => ({ id })),
|
||||
select: ['id', 'tier', 'tier_score'],
|
||||
});
|
||||
const tierMap = new Map(drivers.map(d => [d.id, { tier: d.tier, tier_score: d.tier_score }]));
|
||||
|
||||
const enriched = res.map(([driverId, dist]) => ({
|
||||
driverId,
|
||||
distanceKm: Number(Number(dist).toFixed(3)),
|
||||
tier: tierMap.get(driverId)?.tier || 'bronze' as DriverTier,
|
||||
tierScore: tierMap.get(driverId)?.tier_score || 0,
|
||||
}));
|
||||
|
||||
// المسافة هي المعيار الأساسي. الشريحة كسر تعادل فقط ضمن نطاق ±0.5 كم:
|
||||
// سائق برونزياً على 200 م يسبق بلاتينياً على 9 كم.
|
||||
const TIER_TOLERANCE_KM = 0.5;
|
||||
enriched.sort((a, b) => {
|
||||
const distDiff = a.distanceKm - b.distanceKm;
|
||||
if (Math.abs(distDiff) > TIER_TOLERANCE_KM) return distDiff;
|
||||
return (TIER_PRIORITY[b.tier!] || 0) - (TIER_PRIORITY[a.tier!] || 0);
|
||||
});
|
||||
|
||||
return enriched.slice(0, count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { DeviceToken } from './entities/device-token.entity';
|
||||
import { I18nService } from '../../common/i18n/i18n.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
||||
import * as admin from 'firebase-admin';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface PushOptions {
|
||||
/** رسالة بيانات فقط — لا يعرضها النظام، يتولّاها التطبيق (overlay أندرويد، docs/17 A7). */
|
||||
@@ -24,7 +26,7 @@ interface CachedToken {
|
||||
* بلا FCM_SERVER_KEY: يُسجَّل فقط دون إرسال.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
export class NotificationsService implements OnModuleInit {
|
||||
private readonly logger = new Logger('Notifications');
|
||||
|
||||
constructor(
|
||||
@@ -35,6 +37,23 @@ export class NotificationsService {
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
const credentialsPath = this.config.get<string>('fcm.credentialsPath');
|
||||
if (credentialsPath && admin.apps.length === 0) {
|
||||
try {
|
||||
const fullPath = path.resolve(process.cwd(), credentialsPath);
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(fullPath),
|
||||
});
|
||||
this.logger.log(`Firebase Admin SDK initialized with ${credentialsPath}`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Failed to initialize Firebase Admin SDK: ${err.message}`);
|
||||
}
|
||||
} else if (!credentialsPath) {
|
||||
this.logger.warn('FCM credentials path not found. Push disabled.');
|
||||
}
|
||||
}
|
||||
|
||||
async register(tenantId: string, userId: string, token: string, platform = 'android') {
|
||||
const existing = await this.tokens.findOne({ where: { token } });
|
||||
let saved: DeviceToken;
|
||||
@@ -137,47 +156,52 @@ export class NotificationsService {
|
||||
data: Record<string, any>,
|
||||
opts: PushOptions,
|
||||
) {
|
||||
const key = this.config.get<string>('fcm.serverKey');
|
||||
if (!key || rows.length === 0) {
|
||||
this.logger.debug(`push skip (key=${!!key} tokens=${rows.length}) "${title}"`);
|
||||
if (admin.apps.length === 0 || rows.length === 0) {
|
||||
this.logger.debug(`push skip (adminInitialized=${admin.apps.length > 0} tokens=${rows.length}) "${title}"`);
|
||||
return;
|
||||
}
|
||||
const endpoint = this.config.get<string>('fcm.endpoint')!;
|
||||
|
||||
// FCM يقبل نصوصاً فقط في data — والـoverlay يقرأ العنوان/النص من هنا.
|
||||
const payload = this.stringifyData({ ...data, title, body });
|
||||
const tokens = rows.map((r) => r.token);
|
||||
|
||||
const stale: string[] = [];
|
||||
await Promise.all(
|
||||
rows.map(async (r) => {
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `key=${key}` },
|
||||
body: JSON.stringify({
|
||||
to: r.token,
|
||||
// أولوية عالية: يوقظ التطبيق في الخلفية (شرط الـoverlay).
|
||||
priority: 'high',
|
||||
...(opts.dataOnly ? {} : { notification: { title, body } }),
|
||||
data: payload,
|
||||
}),
|
||||
});
|
||||
const json: any = await res.json().catch(() => null);
|
||||
if (json?.results?.[0]?.error === 'NotRegistered' ||
|
||||
json?.results?.[0]?.error === 'InvalidRegistration') {
|
||||
stale.push(r.token);
|
||||
try {
|
||||
const message: admin.messaging.MulticastMessage = {
|
||||
tokens,
|
||||
data: payload,
|
||||
android: {
|
||||
priority: 'high',
|
||||
},
|
||||
};
|
||||
|
||||
if (!opts.dataOnly) {
|
||||
message.notification = { title, body };
|
||||
}
|
||||
|
||||
const res = await admin.messaging().sendEachForMulticast(message);
|
||||
|
||||
const stale: string[] = [];
|
||||
res.responses.forEach((resp, idx) => {
|
||||
if (!resp.success) {
|
||||
const errCode = resp.error?.code;
|
||||
if (errCode === 'messaging/invalid-registration-token' ||
|
||||
errCode === 'messaging/registration-token-not-registered') {
|
||||
stale.push(tokens[idx]);
|
||||
} else {
|
||||
this.logger.warn(`push failed for token ${tokens[idx]}: ${resp.error?.message}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`push failed: ${e?.message}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// توكن ميت = إرسال ضائع لكل رحلة لاحقة؛ نحذفه فور ما يخبرنا FCM.
|
||||
// والكاش يُبطَل معه، وإلا بقي التوكن الميت يُستعمل حتى انتهاء عمر المفتاح.
|
||||
if (stale.length > 0) {
|
||||
await this.tokens.delete({ token: In(stale) });
|
||||
await this.cache.del(CacheKeys.deviceTokens(tenantId, userId));
|
||||
this.logger.debug(`removed ${stale.length} stale token(s)`);
|
||||
// توكن ميت = إرسال ضائع لكل رحلة لاحقة؛ نحذفه فور ما يخبرنا FCM.
|
||||
// والكاش يُبطَل معه، وإلا بقي التوكن الميت يُستعمل حتى انتهاء عمر المفتاح.
|
||||
if (stale.length > 0) {
|
||||
await this.tokens.delete({ token: In(stale) });
|
||||
await this.cache.del(CacheKeys.deviceTokens(tenantId, userId));
|
||||
this.logger.debug(`removed ${stale.length} stale token(s)`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`push batch failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -272,4 +272,23 @@ export class ReferralsService {
|
||||
ref: `${referral.id}:${side}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* صرف الإحالات المؤهَّلة (O5 — يُستدعى من CronWorker).
|
||||
* شبكة أمان: ما فُصِل يُعاد محاولته في الدورة القادمة.
|
||||
* @returns عدد الإحالات التي نجح صرفها.
|
||||
*/
|
||||
async sweep(batchSize = 50): Promise<number> {
|
||||
const due = await this.findQualified(batchSize);
|
||||
let paid = 0;
|
||||
for (const referral of due) {
|
||||
try {
|
||||
await this.payout(referral);
|
||||
paid++;
|
||||
} catch (e: any) {
|
||||
this.logger.error(`sweep payout failed for ${referral.id}: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
return paid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ReferralCode } from './entities/referral-code.entity';
|
||||
import { CouponsService } from './coupons.service';
|
||||
import { ReferralsService } from './referrals.service';
|
||||
import { RewardsController } from './rewards.controller';
|
||||
import { RewardsSweeper } from './rewards.sweeper';
|
||||
import { CreditModule } from '../credit/credit.module';
|
||||
import { DriversModule } from '../drivers/drivers.module';
|
||||
import { TenantsModule } from '../tenants/tenants.module';
|
||||
@@ -27,7 +26,7 @@ import { TenantsModule } from '../tenants/tenants.module';
|
||||
TenantsModule,
|
||||
],
|
||||
controllers: [RewardsController],
|
||||
providers: [CouponsService, ReferralsService, RewardsSweeper],
|
||||
providers: [CouponsService, ReferralsService],
|
||||
exports: [CouponsService, ReferralsService],
|
||||
})
|
||||
export class RewardsModule {}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { TariffService } from './tariff.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Tariff, TariffDefinition } from './entities/tariff.entity';
|
||||
|
||||
/**
|
||||
* CRUD التعرفة للوحة الأدمن (docs/22 — B9).
|
||||
*
|
||||
* كل المسارات محصورة ضمن المستأجر الحالي (من التوكن). لا يمكن لأدمن
|
||||
* مستأجر تعديل تعرفة مستأجر آخر.
|
||||
*
|
||||
* التحديث يُنشئ نسخة جديدة (version + 1) ويُعطّل القديمة — لا يُعدّل
|
||||
* صفّاً تشير إليه رحلات جارية عبر tariff_id + tariff_version.
|
||||
*/
|
||||
@ApiTags('admin-tariffs')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@Controller('admin/tariffs')
|
||||
export class AdminTariffController {
|
||||
constructor(private readonly tariff: TariffService) {}
|
||||
|
||||
/** كل تعرفات المستأجر (بما فيها المُعطّلة). */
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.tariff.list(user.tenantId);
|
||||
}
|
||||
|
||||
/** تعرفة واحدة بالـID. */
|
||||
@Get(':id')
|
||||
get(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.tariff.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
/** إنشاء تعرفة جديدة. */
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: { definition: TariffDefinition; city?: string; service_class?: string },
|
||||
) {
|
||||
return this.tariff.create({
|
||||
tenant_id: user.tenantId,
|
||||
city: body.city ?? 'default',
|
||||
service_class: body.service_class ?? 'economy',
|
||||
definition: body.definition,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** تحديث تعرفة: يُنشئ نسخة جديدة version + 1 ويُعطّل القديمة. */
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: { definition?: TariffDefinition; city?: string; service_class?: string },
|
||||
) {
|
||||
return this.tariff.update(user.tenantId, id, body);
|
||||
}
|
||||
|
||||
/** تعطيل تعرفة (تعطيل ناعم — لا يحذف الصف). */
|
||||
@Patch(':id/deactivate')
|
||||
deactivate(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.tariff.deactivate(user.tenantId, id);
|
||||
}
|
||||
|
||||
/** إعادة تفعيل تعرفة مُعطّلة. */
|
||||
@Patch(':id/activate')
|
||||
activate(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.tariff.activate(user.tenantId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* حالة التسعير الديناميكي لكل (مستأجر × مدينة × فئة خدمة) (L4).
|
||||
*
|
||||
* الجدول: `tripz_surge_states`.
|
||||
*
|
||||
* بوت `SurgeService` يحدّث هذا الجدول دورياً (كل 3 دقائق).
|
||||
* `TariffEngine` لا يقرأه مباشرة — بل يقرأ `surge.multiplier` في `TariffDefinition`
|
||||
* الذي يُحدَّث هنا. الفصل يمنع التشتت: التعرفة هي مصدر الحقيقة، هذا الجدول
|
||||
* هو الذاكرة المؤقتة لقرارات البوت.
|
||||
*/
|
||||
@Entity('surge_states')
|
||||
@Index(['tenant_id', 'city', 'service_class'], { unique: true })
|
||||
export class SurgeState {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column()
|
||||
city: string;
|
||||
|
||||
@Column()
|
||||
service_class: string;
|
||||
|
||||
/** المضاعف الحالي الفعلي (1.0 = لا زيادة). */
|
||||
@Column('decimal', { precision: 5, scale: 2, default: 1.0 })
|
||||
multiplier: number;
|
||||
|
||||
/** الحدّ الأقصى للمضاعف (لا يتجاوزه البوت). */
|
||||
@Column('decimal', { precision: 5, scale: 2, default: 2.0 })
|
||||
max_multiplier: number;
|
||||
|
||||
/** الحدّ الأدنى (لا ينزل عنده). */
|
||||
@Column('decimal', { precision: 5, scale: 2, default: 1.0 })
|
||||
min_multiplier: number;
|
||||
|
||||
/**
|
||||
* عدد الرحلات المُلغيّة/المرفوضة في النافذة الأخيرة — مؤشر الطلب الزائد.
|
||||
* يُصفَّر مع كل دورة.
|
||||
*/
|
||||
@Column({ type: 'int', default: 0 })
|
||||
demand_count: number;
|
||||
|
||||
/**
|
||||
* عدد السائقين المتاحين في النافذة الأخيرة — مؤشر العرض.
|
||||
* يُحسب من تحديثات السائقين النشطين (online + idle).
|
||||
*/
|
||||
@Column({ type: 'int', default: 0 })
|
||||
supply_count: number;
|
||||
|
||||
/** آخر تحديث للبيانات (من بوت العرض/الطلب). */
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
last_sample_at: Date;
|
||||
|
||||
/**
|
||||
* لحظة آخر تغيير للمضاعف. يمنع التحديث المتكرر السريع:
|
||||
* مضاعف لا يتغيّر إلا إذا مرّ `cooldownMs` منذ آخر تغيير.
|
||||
*/
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
last_changed_at: Date | null;
|
||||
|
||||
/** هل المضاعف مُكتَّب يدوياً (override)?一旦 لا يكتبه البوت. */
|
||||
@Column({ default: false })
|
||||
manual_override: boolean;
|
||||
|
||||
/** ملاحظة يدوية (مثلاً: "event surge"). */
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
manual_note: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -90,4 +90,12 @@ export interface TariffDefinition {
|
||||
|
||||
/** نسبة خصم مطابقة الوجهة (docs/17 — B8). غيابها = لا خصم. */
|
||||
destination_match_discount_pct?: number;
|
||||
|
||||
/**
|
||||
* تسعير بالوزن (L3): الشحنات والطرود.
|
||||
* `per_kg_above`: سعر الكيلو فوق `free_kg` (مجاني لأول N كيلو).
|
||||
* `free_kg`: الحدّ المجاني — تحته لا رسوم وزن.
|
||||
* بدون هذه المُعدّات لا يُضاف أيّ شيء بسبب الوزن.
|
||||
*/
|
||||
weight?: { free_kg: number; per_kg_above: number };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
import { SurgeService } from './surge.service';
|
||||
import { PlatformGuard } from '../../common/platform/platform.guard';
|
||||
|
||||
/**
|
||||
* التسعير الديناميكي — لوحة السوبر-أدن (L4).
|
||||
*
|
||||
* المسارات خلف `PlatformGuard` — لا يمكن لأدمن مستأجر الوصول.
|
||||
*/
|
||||
@ApiTags('surge')
|
||||
@Controller()
|
||||
export class SurgeController {
|
||||
constructor(private readonly surge: SurgeService) {}
|
||||
|
||||
/** كل الحالات النشطة لمستأجر. */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/surge/:tenantId')
|
||||
listStates(@Param('tenantId') tenantId: string) {
|
||||
return this.surge.listStates(tenantId);
|
||||
}
|
||||
|
||||
/** حالةفئة معينة. */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/surge/:tenantId/:city/:serviceClass')
|
||||
getState(
|
||||
@Param('tenantId') tenantId: string,
|
||||
@Param('city') city: string,
|
||||
@Param('serviceClass') serviceClass: string,
|
||||
) {
|
||||
return this.surge.getState(tenantId, city, serviceClass);
|
||||
}
|
||||
|
||||
/** إطلاق إعادة حساب يدوية (خارج الدورة الأوتوماتيكية). */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Post('admin/surge/recalculate')
|
||||
recalculate() {
|
||||
return this.surge.recalculateAll();
|
||||
}
|
||||
|
||||
/** تجاوز يدوي للمضاعف. */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Post('admin/surge/override')
|
||||
override(
|
||||
@Body() body: {
|
||||
tenant_id: string;
|
||||
city: string;
|
||||
service_class: string;
|
||||
multiplier: number;
|
||||
note?: string;
|
||||
},
|
||||
) {
|
||||
return this.surge.manualOverride(
|
||||
body.tenant_id,
|
||||
body.city,
|
||||
body.service_class,
|
||||
body.multiplier,
|
||||
body.note,
|
||||
);
|
||||
}
|
||||
|
||||
/** إلغاء التحكم اليدوي. */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Patch('admin/surge/release')
|
||||
release(
|
||||
@Body() body: { tenant_id: string; city: string; service_class: string },
|
||||
) {
|
||||
return this.surge.releaseManualOverride(
|
||||
body.tenant_id,
|
||||
body.city,
|
||||
body.service_class,
|
||||
);
|
||||
}
|
||||
|
||||
/** تحديث العرض (عدد السائقين المتاحين). */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Patch('admin/surge/supply')
|
||||
updateSupply(
|
||||
@Body() body: {
|
||||
tenant_id: string;
|
||||
city: string;
|
||||
service_class: string;
|
||||
count: number;
|
||||
},
|
||||
) {
|
||||
return this.surge.updateSupply(
|
||||
body.tenant_id,
|
||||
body.city,
|
||||
body.service_class,
|
||||
body.count,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, LessThan, Repository } from 'typeorm';
|
||||
import { SurgeState } from './entities/surge-state.entity';
|
||||
import { TariffService } from './tariff.service';
|
||||
import { Driver } from '../drivers/entities/driver.entity';
|
||||
|
||||
/**
|
||||
* خدمة التسعير الديناميكي — بوت المضاعف (L4).
|
||||
*
|
||||
* تحسب مضاعف الطلب/العرض لكل (مدينة × فئة) وتحدّث:
|
||||
* 1. `surge_states` — الذاكرة المؤقتة للحالة
|
||||
* 2. `tariffs.definition.surge.multiplier` — مصدر الحقيقة للمحرك
|
||||
*
|
||||
* الجدولة تتم عبر BullMQ (CronModule — O5) لا عبر `setInterval` هنا.
|
||||
*
|
||||
* المعادلة (من docs/22):
|
||||
* ```
|
||||
* ratio = demand / supply (supply = 0 → ratio = max)
|
||||
* multiplier = 1 + (ratio - 1) × sensitivity
|
||||
* multiplier = clamp(multiplier, min, max)
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class SurgeService {
|
||||
private readonly logger = new Logger('Surge');
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SurgeState)
|
||||
private readonly repo: Repository<SurgeState>,
|
||||
@InjectRepository(Driver)
|
||||
private readonly driverRepo: Repository<Driver>,
|
||||
private readonly tariffService: TariffService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* إعادة حساب المضاعفات لجميع الحالات النشطة.
|
||||
* ي refresh عرض السائقين من قاعدة البيانات قبل كل حساب.
|
||||
*/
|
||||
async recalculateAll(): Promise<{ updated: number; unchanged: number }> {
|
||||
const cooldownMs = this.config.get<number>('trips.surgeCooldownMs') ?? 300_000;
|
||||
const sensitivity = this.config.get<number>('trips.surgeSensitivity') ?? 0.3;
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() - cooldownMs);
|
||||
|
||||
// تحديث العرض الفعلي من السائقين المتصلين
|
||||
await this.refreshSupplyFromDrivers(now);
|
||||
|
||||
const states = await this.repo.find({
|
||||
where: [
|
||||
{ manual_override: false, last_changed_at: LessThan(cutoff) },
|
||||
{ manual_override: false, last_changed_at: IsNull() },
|
||||
],
|
||||
});
|
||||
|
||||
let updated = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
for (const state of states) {
|
||||
const newMultiplier = this.calculateMultiplier(
|
||||
state.demand_count,
|
||||
state.supply_count,
|
||||
Number(state.min_multiplier),
|
||||
Number(state.max_multiplier),
|
||||
sensitivity,
|
||||
);
|
||||
|
||||
const oldMultiplier = Number(state.multiplier);
|
||||
|
||||
// لا تحديث لو المضاعف لم يتغيّر بشكل ملموس
|
||||
if (Math.abs(newMultiplier - oldMultiplier) < 0.01) {
|
||||
unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// تحديث مضاعف التعرفة
|
||||
await this.tariffService.updateSurgeMultiplier(
|
||||
state.tenant_id,
|
||||
state.city,
|
||||
state.service_class,
|
||||
newMultiplier,
|
||||
);
|
||||
|
||||
state.multiplier = newMultiplier;
|
||||
state.last_changed_at = now;
|
||||
await this.repo.save(state);
|
||||
|
||||
this.logger.log(
|
||||
`${state.city}/${state.service_class}: ${oldMultiplier.toFixed(2)}x → ${newMultiplier.toFixed(2)}x ` +
|
||||
`(demand=${state.demand_count}, supply=${state.supply_count})`,
|
||||
);
|
||||
updated++;
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
this.logger.log(`surge update: ${updated} changed, ${unchanged} unchanged`);
|
||||
}
|
||||
|
||||
// تصفير العدادات لكل حالة تم حسابها — نافذة القياس = دورة واحدة (docs/22).
|
||||
for (const state of states) {
|
||||
state.demand_count = 0;
|
||||
state.supply_count = 0;
|
||||
}
|
||||
if (states.length > 0) {
|
||||
await this.repo.save(states);
|
||||
}
|
||||
|
||||
return { updated, unchanged };
|
||||
}
|
||||
|
||||
/**
|
||||
* حساب المضاعف من نسبة الطلب/العرض.
|
||||
*
|
||||
* ```
|
||||
* ratio = demand / max(supply, 1)
|
||||
* multiplier = 1 + (ratio - 1) × sensitivity
|
||||
* ```
|
||||
*
|
||||
* - لا عرض (0 سائقين) → ratio = max → multiplier = max_multiplier
|
||||
* - عرض ≥ طلب → multiplier = 1.0 (لا زيادة)
|
||||
*/
|
||||
calculateMultiplier(
|
||||
demand: number,
|
||||
supply: number,
|
||||
min: number,
|
||||
max: number,
|
||||
sensitivity: number,
|
||||
): number {
|
||||
if (demand <= 0) return 1.0;
|
||||
|
||||
const effectiveSupply = Math.max(supply, 1);
|
||||
const ratio = demand / effectiveSupply;
|
||||
|
||||
// لا زيادة لو العرض كافٍ أو أكبر
|
||||
if (ratio <= 1.0) return 1.0;
|
||||
|
||||
const raw = 1 + (ratio - 1) * sensitivity;
|
||||
const clamped = Math.min(max, Math.max(min, raw));
|
||||
|
||||
// تقريب لخانتين عشريتين
|
||||
return Math.round(clamped * 100) / 100;
|
||||
}
|
||||
|
||||
// ─── تحديث البيانات (من بوتات أخرى أو API) ────────────────────────
|
||||
|
||||
/** تحديث عدّاد الطلب (يُستدعى عند إلغاء/رفض رحلة). */
|
||||
async incrementDemand(tenantId: string, city: string, serviceClass: string): Promise<void> {
|
||||
const state = await this.getOrCreate(tenantId, city, serviceClass);
|
||||
state.demand_count++;
|
||||
state.last_sample_at = new Date();
|
||||
await this.repo.save(state);
|
||||
}
|
||||
|
||||
/** تحديث عدّاد العرض (يُستدعى عند تحديث حالة السائق). */
|
||||
async updateSupply(tenantId: string, city: string, serviceClass: string, count: number): Promise<void> {
|
||||
const state = await this.getOrCreate(tenantId, city, serviceClass);
|
||||
state.supply_count = count;
|
||||
state.last_sample_at = new Date();
|
||||
await this.repo.save(state);
|
||||
}
|
||||
|
||||
/** إعادة ضبط العدّادات بعد كل دورة حساب. */
|
||||
async resetCounters(tenantId: string, city: string, serviceClass: string): Promise<void> {
|
||||
const state = await this.getOrCreate(tenantId, city, serviceClass);
|
||||
state.demand_count = 0;
|
||||
state.supply_count = 0;
|
||||
state.last_sample_at = new Date();
|
||||
await this.repo.save(state);
|
||||
}
|
||||
|
||||
// ─── التحكم اليدوي (Admin) ──────────────────────────────────────
|
||||
|
||||
/** تجاوز يدوي للمضاعف (مثلاً: حدث أو زحمة غير عادية). */
|
||||
async manualOverride(
|
||||
tenantId: string,
|
||||
city: string,
|
||||
serviceClass: string,
|
||||
multiplier: number,
|
||||
note?: string,
|
||||
): Promise<SurgeState> {
|
||||
const state = await this.getOrCreate(tenantId, city, serviceClass);
|
||||
state.multiplier = multiplier;
|
||||
state.manual_override = true;
|
||||
state.manual_note = note ?? null;
|
||||
state.last_changed_at = new Date();
|
||||
await this.repo.save(state);
|
||||
|
||||
await this.tariffService.updateSurgeMultiplier(
|
||||
tenantId, city, serviceClass, multiplier,
|
||||
);
|
||||
|
||||
this.logger.log(`manual override: ${city}/${serviceClass} → ${multiplier}x (${note ?? 'no note'})`);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** إلغاء التحكم اليدوي — يعود البوت يحسب تلقائياً. */
|
||||
async releaseManualOverride(tenantId: string, city: string, serviceClass: string): Promise<void> {
|
||||
const state = await this.getOrCreate(tenantId, city, serviceClass);
|
||||
state.manual_override = false;
|
||||
state.manual_note = null;
|
||||
await this.repo.save(state);
|
||||
}
|
||||
|
||||
// ─── الاستعلام ────────────────────────────────────────────────
|
||||
|
||||
/** حالة البوت لفئة معينة. */
|
||||
async getState(tenantId: string, city: string, serviceClass: string): Promise<SurgeState | null> {
|
||||
return this.repo.findOne({ where: { tenant_id: tenantId, city, service_class: serviceClass } });
|
||||
}
|
||||
|
||||
/** كل الحالات النشطة لمستأجر. */
|
||||
async listStates(tenantId: string): Promise<SurgeState[]> {
|
||||
return this.repo.find({ where: { tenant_id: tenantId }, order: { city: 'ASC', service_class: 'ASC' } });
|
||||
}
|
||||
|
||||
// ─── داخلي ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* تحديث عدد السائقين المتاحين لكل حالة surge من قاعدة البيانات الفعلية
|
||||
* بدلاً من الاعتماد على عداد ثابت. يُستدعى قبل كل إعادة حساب.
|
||||
*/
|
||||
private async refreshSupplyFromDrivers(now: Date): Promise<void> {
|
||||
const states = await this.repo.find({ where: { manual_override: false } });
|
||||
for (const state of states) {
|
||||
const count = await this.driverRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.tenant_id = :tenantId', { tenantId: state.tenant_id })
|
||||
.andWhere('d.is_online = true')
|
||||
.andWhere('d.verification_status = :vs', { vs: 'approved' })
|
||||
.andWhere('d.service_class = :sc', { sc: state.service_class })
|
||||
.getCount();
|
||||
state.supply_count = count;
|
||||
state.last_sample_at = now;
|
||||
await this.repo.save(state);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreate(tenantId: string, city: string, serviceClass: string): Promise<SurgeState> {
|
||||
let state = await this.repo.findOne({ where: { tenant_id: tenantId, city, service_class: serviceClass } });
|
||||
if (!state) {
|
||||
state = this.repo.create({
|
||||
tenant_id: tenantId,
|
||||
city,
|
||||
service_class: serviceClass,
|
||||
multiplier: 1.0,
|
||||
max_multiplier: 2.0,
|
||||
min_multiplier: 1.0,
|
||||
});
|
||||
await this.repo.save(state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -79,3 +79,35 @@ describe('TariffEngine.commission — العمولة (docs/18)', () => {
|
||||
expect(TariffEngine.commission(def({ commission: { percent: 10 } }), 0).amount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TariffEngine.quote — تسعير بالوزن (L3)', () => {
|
||||
const WEIGHT_DEF = def({
|
||||
weight: { free_kg: 5, per_kg_above: 0.5 },
|
||||
});
|
||||
|
||||
it('تحت الحد المجاني: لا رسوم وزن', () => {
|
||||
const q = TariffEngine.quote(WEIGHT_DEF, { distanceKm: 10, durationMin: 20, weightKg: 4 });
|
||||
expect(q.weight).toBe(0);
|
||||
});
|
||||
|
||||
it('فوق الحد المجاني: يُحسب الفرق × السعر', () => {
|
||||
const q = TariffEngine.quote(WEIGHT_DEF, { distanceKm: 10, durationMin: 20, weightKg: 10 });
|
||||
// (10 - 5) × 0.5 = 2.5
|
||||
expect(q.weight).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it('بدون إعداد وزن: لا تأثير حتى لو وُزن', () => {
|
||||
const q = TariffEngine.quote(def(), { distanceKm: 10, durationMin: 20, weightKg: 50 });
|
||||
expect(q.weight).toBe(0);
|
||||
});
|
||||
|
||||
it('وزن ثابت + surge', () => {
|
||||
const q = TariffEngine.quote(
|
||||
def({ surge: { enabled: true, multiplier: 1.5 }, weight: { free_kg: 5, per_kg_above: 2 } }),
|
||||
{ distanceKm: 10, durationMin: 20, weightKg: 10 },
|
||||
);
|
||||
// weight charge: (10-5)×2 = 10 → ×1.5 surge = 15
|
||||
expect(q.weight).toBeCloseTo(10);
|
||||
expect(q.total).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface QuoteInput {
|
||||
waitingMin?: number;
|
||||
at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية)
|
||||
geofenceMultiplier?: number;
|
||||
/** وزن الحمولة بالكيلوغرام — للشحنات والطرود فقط (L3). */
|
||||
weightKg?: number;
|
||||
}
|
||||
|
||||
export interface QuoteBreakdown {
|
||||
@@ -14,6 +16,7 @@ export interface QuoteBreakdown {
|
||||
distance: number;
|
||||
time: number;
|
||||
waiting: number;
|
||||
weight: number;
|
||||
bookingFee: number;
|
||||
subtotal: number;
|
||||
surgeMultiplier: number;
|
||||
@@ -38,18 +41,21 @@ export class TariffEngine {
|
||||
|
||||
if (def.mode === 'fixed_quote') {
|
||||
// سعر ثابت متفَق عليه (خط ثابت / وجهة بسعر معلن): لا مسافة ولا زمن.
|
||||
// كان يسقط سابقاً إلى الفرع المتري فيُحسب بالعدّاد رغم اسمه — أي أن
|
||||
// «السعر الثابت» لم يكن ثابتاً أبداً.
|
||||
const fixed = Math.max(0, def.fixed_fare ?? 0);
|
||||
const waitingOnly = waitingMin * (win.per_min_waiting ?? 0);
|
||||
// الانتظار وحده يُضاف: بقاء الراكب ربع ساعة تكلفة حقيقية على السائق
|
||||
// مهما كان الخط ثابتاً. ولا حدّ أدنى هنا — السعر المعلن **هو** الاتفاق،
|
||||
// ورفعه بحدّ أدنى يعني إعلان سعر وتحصيل غيره.
|
||||
|
||||
// --- L3: وزن الحمولة (شحنات على خطوط ثابتة) ---
|
||||
const weightKg = Math.max(0, input.weightKg ?? 0);
|
||||
let weightCharge = 0;
|
||||
if (def.weight && weightKg > def.weight.free_kg) {
|
||||
weightCharge = (weightKg - def.weight.free_kg) * def.weight.per_kg_above;
|
||||
}
|
||||
|
||||
const multiplier =
|
||||
(def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1) *
|
||||
(input.geofenceMultiplier ?? 1.0);
|
||||
const fixedTotal = TariffEngine.round(
|
||||
(fixed + waitingOnly) * multiplier,
|
||||
(fixed + waitingOnly + weightCharge) * multiplier,
|
||||
def.rounding,
|
||||
);
|
||||
return {
|
||||
@@ -58,8 +64,9 @@ export class TariffEngine {
|
||||
distance: 0,
|
||||
time: 0,
|
||||
waiting: TariffEngine.n(waitingOnly),
|
||||
weight: TariffEngine.n(weightCharge),
|
||||
bookingFee: 0,
|
||||
subtotal: TariffEngine.n(fixed + waitingOnly),
|
||||
subtotal: TariffEngine.n(fixed + waitingOnly + weightCharge),
|
||||
surgeMultiplier: multiplier,
|
||||
total: fixedTotal,
|
||||
currency: def.currency,
|
||||
@@ -84,8 +91,15 @@ export class TariffEngine {
|
||||
const waitingCharge = waitingMin * (win.per_min_waiting ?? 0);
|
||||
const bookingFee = def.booking_fee ?? 0;
|
||||
|
||||
// --- L3: وزن الحمولة ---
|
||||
const weightKg = Math.max(0, input.weightKg ?? 0);
|
||||
let weightCharge = 0;
|
||||
if (def.weight && weightKg > def.weight.free_kg) {
|
||||
weightCharge = (weightKg - def.weight.free_kg) * def.weight.per_kg_above;
|
||||
}
|
||||
|
||||
let subtotal =
|
||||
win.flag + distanceCharge + timeCharge + waitingCharge + bookingFee;
|
||||
win.flag + distanceCharge + timeCharge + waitingCharge + weightCharge + bookingFee;
|
||||
|
||||
const surgeMultiplier =
|
||||
def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1;
|
||||
@@ -104,6 +118,7 @@ export class TariffEngine {
|
||||
distance: TariffEngine.n(distanceCharge),
|
||||
time: TariffEngine.n(timeCharge),
|
||||
waiting: TariffEngine.n(waitingCharge),
|
||||
weight: TariffEngine.n(weightCharge),
|
||||
bookingFee,
|
||||
subtotal: TariffEngine.n(subtotal),
|
||||
surgeMultiplier,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Tariff } from './entities/tariff.entity';
|
||||
import { SurgeState } from './entities/surge-state.entity';
|
||||
import { TariffService } from './tariff.service';
|
||||
import { SurgeService } from './surge.service';
|
||||
import { TariffController } from './tariff.controller';
|
||||
import { AdminTariffController } from './admin-tariff.controller';
|
||||
import { SurgeController } from './surge.controller';
|
||||
import { TenantsModule } from '../tenants/tenants.module';
|
||||
import { Driver } from '../drivers/entities/driver.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Tariff]), TenantsModule],
|
||||
controllers: [TariffController],
|
||||
providers: [TariffService],
|
||||
exports: [TariffService],
|
||||
imports: [TypeOrmModule.forFeature([Tariff, SurgeState, Driver]), TenantsModule],
|
||||
controllers: [TariffController, AdminTariffController, SurgeController],
|
||||
providers: [TariffService, SurgeService],
|
||||
exports: [TariffService, SurgeService],
|
||||
})
|
||||
export class TariffModule {}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Tariff } from './entities/tariff.entity';
|
||||
@@ -37,10 +41,75 @@ export class TariffService {
|
||||
|
||||
async create(data: Partial<Tariff>): Promise<Tariff> {
|
||||
const saved = await this.repo.save(this.repo.create(data));
|
||||
// تعرفة جديدة = النسخة الفعّالة تغيّرت → الكاش صار كذباً.
|
||||
await this.cache.del(
|
||||
CacheKeys.tariff(saved.tenant_id, saved.city, saved.service_class),
|
||||
);
|
||||
await this.invalidateCache(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** كل تعرفات المستأجر (بما فيها المُعطّلة). */
|
||||
async list(tenantId: string): Promise<Tariff[]> {
|
||||
return this.repo.find({
|
||||
where: { tenant_id: tenantId },
|
||||
order: { service_class: 'ASC', version: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** تعرفة واحدة بالـID — يرمي NotFoundException لو غير موجودة. */
|
||||
async findById(tenantId: string, id: string): Promise<Tariff> {
|
||||
const t = await this.repo.findOne({
|
||||
where: { tenant_id: tenantId, id },
|
||||
});
|
||||
if (!t) throw new NotFoundException(`Tariff ${id} not found`);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* تحديث تعرفة: يُنشئ نسخة جديدة (version + 1) بدل تعديل الصف الحالي.
|
||||
* السبب: رحلات جارية قد تشير إلى النسخة القديمة عبر `tariff_id` +
|
||||
* `tariff_version`. تعديل الصف يغيّر حقيقة رحلة انتهت.
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
patch: { definition?: Tariff['definition']; city?: string; service_class?: string },
|
||||
): Promise<Tariff> {
|
||||
const prev = await this.findById(tenantId, id);
|
||||
if (!prev.active) {
|
||||
throw new BadRequestException('Cannot update a deactivated tariff');
|
||||
}
|
||||
|
||||
const next = this.repo.create({
|
||||
tenant_id: tenantId,
|
||||
city: patch.city ?? prev.city,
|
||||
service_class: patch.service_class ?? prev.service_class,
|
||||
definition: patch.definition ?? prev.definition,
|
||||
version: prev.version + 1,
|
||||
active: true,
|
||||
});
|
||||
const saved = await this.repo.save(next);
|
||||
|
||||
// تعطيل النسخة القديمة بعد إنشاء النسخة الجديدة.
|
||||
await this.repo.update(prev.id, { active: false });
|
||||
|
||||
await this.invalidateCache(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** تعطيل تعرفة (تعطيل ناعم). */
|
||||
async deactivate(tenantId: string, id: string): Promise<Tariff> {
|
||||
const t = await this.findById(tenantId, id);
|
||||
t.active = false;
|
||||
const saved = await this.repo.save(t);
|
||||
await this.invalidateCache(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** إعادة تفعيل تعرفة مُعطّلة. */
|
||||
async activate(tenantId: string, id: string): Promise<Tariff> {
|
||||
const t = await this.findById(tenantId, id);
|
||||
if (t.active) return t; // فعّالة أصلاً
|
||||
t.active = true;
|
||||
const saved = await this.repo.save(t);
|
||||
await this.invalidateCache(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -59,4 +128,36 @@ export class TariffService {
|
||||
const quote = TariffEngine.quote(tariff.definition, input);
|
||||
return { quote, tariffId: tariff.id, version: tariff.version };
|
||||
}
|
||||
|
||||
/**
|
||||
* تحديث مضاعف التسعير الديناميكي في التعرفة الفعّالة (L4).
|
||||
* يُحدّث `surge.multiplier` في وثيقة التعريف ثم يمسح الكاش.
|
||||
* لا يُنشئ نسخة جديدة — المضاعف مؤقت يتغيّر كل 3 دقائق.
|
||||
*/
|
||||
async updateSurgeMultiplier(
|
||||
tenantId: string,
|
||||
city: string,
|
||||
serviceClass: string,
|
||||
multiplier: number,
|
||||
): Promise<void> {
|
||||
const tariff = await this.repo.findOne({
|
||||
where: { tenant_id: tenantId, city, service_class: serviceClass, active: true },
|
||||
order: { version: 'DESC' },
|
||||
});
|
||||
if (!tariff) return;
|
||||
|
||||
tariff.definition.surge = {
|
||||
enabled: multiplier !== 1.0,
|
||||
multiplier,
|
||||
max: tariff.definition.surge?.max ?? 2.0,
|
||||
};
|
||||
await this.repo.save(tariff);
|
||||
await this.invalidateCache(tariff);
|
||||
}
|
||||
|
||||
private async invalidateCache(tariff: Tariff) {
|
||||
await this.cache.del(
|
||||
CacheKeys.tariff(tariff.tenant_id, tariff.city, tariff.service_class),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,4 +70,75 @@ export class TenantWalletController {
|
||||
body.currency || 'JOD',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── تقارير المستأجر (P4) ───────────────────────────────────────
|
||||
|
||||
/** إيراد حسب مزوّد الدفع (cash / cliq / syriatel / mtn / ...). */
|
||||
@Get('report/by-provider')
|
||||
reportByProvider(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('days') days?: string,
|
||||
) {
|
||||
const n = Number(days);
|
||||
return this.wallet.revenueByProvider(
|
||||
user.tenantId,
|
||||
Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30,
|
||||
);
|
||||
}
|
||||
|
||||
/** إيراد يومي — سلسلة زمنية. */
|
||||
@Get('report/by-day')
|
||||
reportByDay(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('days') days?: string,
|
||||
@Query('currency') currency?: string,
|
||||
) {
|
||||
const n = Number(days);
|
||||
return this.wallet.revenueByDay(
|
||||
user.tenantId,
|
||||
Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30,
|
||||
currency || 'JOD',
|
||||
);
|
||||
}
|
||||
|
||||
/** ملخّص الرحلات المالي (GMV + عمولة + خصومات). */
|
||||
@Get('report/trips')
|
||||
reportTrips(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('days') days?: string,
|
||||
) {
|
||||
const n = Number(days);
|
||||
return this.wallet.tripRevenueSummary(
|
||||
user.tenantId,
|
||||
Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30,
|
||||
);
|
||||
}
|
||||
|
||||
/** ملخّص سحوبات السائقين. */
|
||||
@Get('report/payouts')
|
||||
reportPayouts(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('days') days?: string,
|
||||
) {
|
||||
const n = Number(days);
|
||||
return this.wallet.payoutSummary(
|
||||
user.tenantId,
|
||||
Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30,
|
||||
);
|
||||
}
|
||||
|
||||
/** التقرير الشامل — يجمع كل شيء في طلب واحد. */
|
||||
@Get('report/full')
|
||||
reportFull(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('days') days?: string,
|
||||
@Query('currency') currency?: string,
|
||||
) {
|
||||
const n = Number(days);
|
||||
return this.wallet.fullReport(
|
||||
user.tenantId,
|
||||
Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30,
|
||||
currency || 'JOD',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,22 @@ import {
|
||||
TenantRevenueEntry,
|
||||
TenantPendingEntry,
|
||||
} from './entities/tenant-ledger.entity';
|
||||
import { Payment } from '../payments/entities/payment.entity';
|
||||
import { Payout } from '../payments/entities/payout.entity';
|
||||
import { Trip } from '../trips/entities/trip.entity';
|
||||
import { TenantWalletService } from './tenant-wallet.service';
|
||||
import { TenantWalletController } from './tenant-wallet.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TenantRevenueEntry, TenantPendingEntry])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
TenantRevenueEntry,
|
||||
TenantPendingEntry,
|
||||
Payment,
|
||||
Payout,
|
||||
Trip,
|
||||
]),
|
||||
],
|
||||
controllers: [TenantWalletController],
|
||||
providers: [TenantWalletService],
|
||||
exports: [TenantWalletService],
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
TenantPendingEntry,
|
||||
LedgerReason,
|
||||
} from './entities/tenant-ledger.entity';
|
||||
import { Payment } from '../payments/entities/payment.entity';
|
||||
import { Payout } from '../payments/entities/payout.entity';
|
||||
import { Trip } from '../trips/entities/trip.entity';
|
||||
import { TenantWalletService } from './tenant-wallet.service';
|
||||
|
||||
const TENANT = '11111111-1111-1111-1111-111111111111';
|
||||
@@ -49,6 +52,9 @@ describe('TenantWalletService — محفظتا المستأجر (docs/24)', () =
|
||||
wallet = new TenantWalletService(
|
||||
ds.getRepository(TenantRevenueEntry),
|
||||
ds.getRepository(TenantPendingEntry),
|
||||
{} as any, // Payment repo (not used in these tests)
|
||||
{} as any, // Payout repo (not used in these tests)
|
||||
{} as any, // Trip repo (not used in these tests)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
TenantLedgerBase,
|
||||
LedgerReason,
|
||||
} from './entities/tenant-ledger.entity';
|
||||
import { Payment } from '../payments/entities/payment.entity';
|
||||
import { Payout } from '../payments/entities/payout.entity';
|
||||
import { Trip } from '../trips/entities/trip.entity';
|
||||
|
||||
/** أي الدفترين — يُمرَّر صراحةً في كل نداء فلا يوجد افتراض صامت. */
|
||||
export type Book = 'revenue' | 'pending';
|
||||
@@ -36,6 +39,12 @@ export class TenantWalletService {
|
||||
private readonly revenue: Repository<TenantRevenueEntry>,
|
||||
@InjectRepository(TenantPendingEntry)
|
||||
private readonly pending: Repository<TenantPendingEntry>,
|
||||
@InjectRepository(Payment)
|
||||
private readonly payments: Repository<Payment>,
|
||||
@InjectRepository(Payout)
|
||||
private readonly payouts: Repository<Payout>,
|
||||
@InjectRepository(Trip)
|
||||
private readonly trips: Repository<Trip>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -192,4 +201,138 @@ export class TenantWalletService {
|
||||
}
|
||||
|
||||
static readonly Reason = LedgerReason;
|
||||
|
||||
// ─── تقارير المستأجر (P4) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* إيراد حسب مزوّد الدفع — يجيب «بأي وسيلة دفع دفع الراكب/السائق؟».
|
||||
* يقرأ من `pay_payments` (المدفوعات الناجحة فقط).
|
||||
*/
|
||||
async revenueByProvider(tenantId: string, days = 30) {
|
||||
const since = new Date(Date.now() - days * 86400_000);
|
||||
const rows = await this.payments
|
||||
.createQueryBuilder('p')
|
||||
.select('p.provider', 'provider')
|
||||
.addSelect('p.purpose', 'purpose')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.addSelect('COALESCE(SUM(p.amount), 0)', 'total_amount')
|
||||
.where('p.tenant_id = :tenantId AND p.status = :status', { tenantId, status: 'success' })
|
||||
.andWhere('p.created_at >= :since', { since })
|
||||
.groupBy('p.provider')
|
||||
.addGroupBy('p.purpose')
|
||||
.orderBy('total_amount', 'DESC')
|
||||
.getRawMany();
|
||||
|
||||
return rows.map((r: any) => ({
|
||||
provider: r.provider,
|
||||
purpose: r.purpose,
|
||||
count: Number(r.count),
|
||||
total_amount: Number(r.total_amount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* إيراد يومي — سلسلة زمنية للإيراد.
|
||||
* يقرأ من دفتر الإيرادات (العمولات + شحن السائقين + رسوم المعاملات).
|
||||
*/
|
||||
async revenueByDay(tenantId: string, days = 30, currency = 'JOD') {
|
||||
const since = new Date(Date.now() - days * 86400_000);
|
||||
const rows = await this.revenue
|
||||
.createQueryBuilder('l')
|
||||
.select("DATE_TRUNC('day', l.created_at)", 'date')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.addSelect('COALESCE(SUM(l.amount), 0)', 'total')
|
||||
.where('l.tenant_id = :tenantId AND l.currency = :currency', { tenantId, currency })
|
||||
.andWhere('l.created_at >= :since', { since })
|
||||
.groupBy("DATE_TRUNC('day', l.created_at)")
|
||||
.orderBy("DATE_TRUNC('day', l.created_at)", 'ASC')
|
||||
.getRawMany();
|
||||
|
||||
return rows.map((r: any) => ({
|
||||
date: r.date,
|
||||
count: Number(r.count),
|
||||
total: Number(r.total),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* ملخّص الرحلات المالي — GMV + عمولة + خصومات + كوبونات.
|
||||
* يقرأ من جدول الرحلات المكتملة والمدفوعة.
|
||||
*/
|
||||
async tripRevenueSummary(tenantId: string, days = 30) {
|
||||
const since = new Date(Date.now() - days * 86400_000);
|
||||
const row = await this.trips
|
||||
.createQueryBuilder('t')
|
||||
.select('COUNT(*)', 'total_trips')
|
||||
.addSelect("COUNT(*) FILTER (WHERE t.status = 'completed')", 'completed_trips')
|
||||
.addSelect("COALESCE(SUM(t.final_fare) FILTER (WHERE t.status = 'completed'), 0)", 'gmv')
|
||||
.addSelect("COALESCE(SUM(t.commission_amount) FILTER (WHERE t.status = 'completed'), 0)", 'commission')
|
||||
.addSelect("COALESCE(SUM(t.coupon_discount) FILTER (WHERE t.status = 'completed'), 0)", 'coupon_discount')
|
||||
.addSelect("COALESCE(SUM(t.destination_match_discount) FILTER (WHERE t.status = 'completed'), 0)", 'destination_discount')
|
||||
.addSelect("COALESCE(SUM(t.cancel_fee) FILTER (WHERE t.status IN ('cancelled','expired') AND t.cancel_fee > 0), 0)", 'cancel_fees')
|
||||
.where('t.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('t.created_at >= :since', { since })
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
total_trips: Number(row?.total_trips ?? 0),
|
||||
completed_trips: Number(row?.completed_trips ?? 0),
|
||||
gmv: Number(row?.gmv ?? 0),
|
||||
commission: Number(row?.commission ?? 0),
|
||||
coupon_discount: Number(row?.coupon_discount ?? 0),
|
||||
destination_discount: Number(row?.destination_discount ?? 0),
|
||||
cancel_fees: Number(row?.cancel_fees ?? 0),
|
||||
net_revenue: Number(row?.commission ?? 0) - Number(row?.coupon_discount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ملخّص سحوبات السائقين — pending / processing / paid / failed.
|
||||
*/
|
||||
async payoutSummary(tenantId: string, days = 30) {
|
||||
const since = new Date(Date.now() - days * 86400_000);
|
||||
const rows = await this.payouts
|
||||
.createQueryBuilder('p')
|
||||
.select('p.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.addSelect('COALESCE(SUM(p.amount), 0)', 'total_amount')
|
||||
.where('p.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('p.created_at >= :since', { since })
|
||||
.groupBy('p.status')
|
||||
.getRawMany();
|
||||
|
||||
const summary: Record<string, { count: number; total_amount: number }> = {};
|
||||
for (const r of rows) {
|
||||
summary[r.status] = {
|
||||
count: Number(r.count),
|
||||
total_amount: Number(r.total_amount),
|
||||
};
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* التقرير الشامل (P4) — يجمع كل شيء في طلب واحد.
|
||||
*/
|
||||
async fullReport(tenantId: string, days = 30, currency = 'JOD') {
|
||||
const [summary, byReason, byProvider, byDay, tripSummary, payoutSummary] = await Promise.all([
|
||||
this.summary(tenantId, currency),
|
||||
this.revenueByReason(tenantId, days, currency),
|
||||
this.revenueByProvider(tenantId, days),
|
||||
this.revenueByDay(tenantId, days, currency),
|
||||
this.tripRevenueSummary(tenantId, days),
|
||||
this.payoutSummary(tenantId, days),
|
||||
]);
|
||||
|
||||
return {
|
||||
period_days: days,
|
||||
currency,
|
||||
wallet: summary,
|
||||
revenue_by_reason: byReason,
|
||||
revenue_by_provider: byProvider,
|
||||
revenue_by_day: byDay,
|
||||
trips: tripSummary,
|
||||
payouts: payoutSummary,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PAYMENT_METHODS } from '../../common/entitlements/payment-methods';
|
||||
import { PlatformGuard } from '../../common/platform/platform.guard';
|
||||
import { StorageService } from '../../common/storage/storage.service';
|
||||
import { readImageMeta, validateLogo } from '../../common/storage/image-meta';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
|
||||
@ApiTags('tenants')
|
||||
@Controller()
|
||||
@@ -28,6 +29,7 @@ export class TenantsController {
|
||||
constructor(
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly catalog: CatalogService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -89,12 +91,21 @@ export class TenantsController {
|
||||
return this.tenants.platformOverview(Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30);
|
||||
}
|
||||
|
||||
/** كتالوج الميزات ووسائل الدفع — تعرضهما لوحة السوبر-أدمن عند التزويد. */
|
||||
/** كتالوج الميزات ووسائل الدفع — يقرأ من القاعدة (N6) مع fallback للكود الثابت. */
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Get('admin/features')
|
||||
features() {
|
||||
return { features: FEATURES, payment_methods: PAYMENT_METHODS };
|
||||
async features() {
|
||||
let items;
|
||||
try {
|
||||
items = await this.catalog.list();
|
||||
} catch {
|
||||
items = [];
|
||||
}
|
||||
const features = items.length > 0
|
||||
? items.map((i) => ({ key: i.key, name_ar: i.name_ar, name_en: i.name_en, category: i.category, monthly_price: i.monthly_price, setup_fee: i.setup_fee }))
|
||||
: FEATURES;
|
||||
return { features, payment_methods: PAYMENT_METHODS };
|
||||
}
|
||||
|
||||
@ApiSecurity('x-platform-secret')
|
||||
|
||||
@@ -6,13 +6,14 @@ import { TenantRevenueEntry } from '../tenant-wallet/entities/tenant-ledger.enti
|
||||
import { Tariff } from '../tariff/entities/tariff.entity';
|
||||
import { TenantsService } from './tenants.service';
|
||||
import { TenantsController } from './tenants.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
|
||||
@Module({
|
||||
// Trip ودفتر الإيراد هنا **للقراءة فقط** — تقرير GMV والإيراد للسوبر-أدمن
|
||||
// (docs/22 — N1، docs/24 — P5). الكتابة في الدفتر تخصّ TenantWalletService وحده.
|
||||
// `Tariff` هنا لزرع تعرفة الانطلاق عند التزويد. المستودع مباشرةً لا
|
||||
// `TariffModule` — تلك الوحدة تستورد هذه، فالاستيراد المتبادل دورة.
|
||||
imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry, Tariff])],
|
||||
imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry, Tariff]), CatalogModule],
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService],
|
||||
exports: [TenantsService],
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS } from '../../common/redis/redis.module';
|
||||
import { RealtimeGateway } from '../../realtime/realtime.gateway';
|
||||
|
||||
export interface BusArrivalEvent {
|
||||
tripId: string;
|
||||
driverId: string;
|
||||
routeId: string;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitBusListener implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(TransitBusListener.name);
|
||||
private subscriber?: Redis;
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS) private readonly redis: Redis,
|
||||
private readonly realtime: RealtimeGateway,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.subscriber = this.redis.duplicate();
|
||||
this.subscriber.subscribe('channel:bus-arrived', (err) => {
|
||||
if (err) {
|
||||
this.logger.error(`subscribe bus-arrived failed: ${err.message}`);
|
||||
} else {
|
||||
this.logger.log('subscribed to channel:bus-arrived');
|
||||
}
|
||||
});
|
||||
|
||||
this.subscriber.on('message', (channel, message) => {
|
||||
if (channel !== 'channel:bus-arrived') return;
|
||||
try {
|
||||
const event: BusArrivalEvent = JSON.parse(message);
|
||||
this.handleBusArrival(event);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`bus-arrived parse error: ${e?.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.subscriber) {
|
||||
await this.subscriber.unsubscribe('channel:bus-arrived');
|
||||
await this.subscriber.quit();
|
||||
}
|
||||
}
|
||||
|
||||
private handleBusArrival(event: BusArrivalEvent): void {
|
||||
this.logger.log(`bus arrived: trip=${event.tripId} station=${event.stationName}`);
|
||||
|
||||
if (this.realtime?.server) {
|
||||
this.realtime.server.to(`tenant:*:trip:${event.tripId}`).emit('bus:arrived', {
|
||||
tripId: event.tripId,
|
||||
stationId: event.stationId,
|
||||
stationName: event.stationName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Controller, Get, Post, Param, Body, UseGuards, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
|
||||
import { FeatureGuard, RequiresFeature } from '../../common/entitlements/feature.guard';
|
||||
import { TransitService } from './transit.service';
|
||||
|
||||
@ApiTags('transit')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, FeatureGuard)
|
||||
@Controller('transit')
|
||||
export class TransitController {
|
||||
constructor(private readonly transitService: TransitService) {}
|
||||
|
||||
@Get('routes')
|
||||
@RequiresFeature('transit')
|
||||
async getRoutes(@CurrentUser() user: AuthUser) {
|
||||
return this.transitService.getRoutes(user.tenantId);
|
||||
}
|
||||
|
||||
@Get('routes/:routeId/stations')
|
||||
@RequiresFeature('transit')
|
||||
async getStations(@Param('routeId') routeId: string) {
|
||||
return this.transitService.getStations(routeId);
|
||||
}
|
||||
|
||||
@Post('trips/start')
|
||||
@RequiresFeature('transit')
|
||||
@Roles('admin', 'dispatcher')
|
||||
async startTrip(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: { routeId: string; driverId: string },
|
||||
) {
|
||||
return this.transitService.startTrip(body.routeId, body.driverId);
|
||||
}
|
||||
|
||||
@Post('trips/:tripId/end')
|
||||
@RequiresFeature('transit')
|
||||
@Roles('admin', 'dispatcher')
|
||||
async endTrip(@Param('tripId') tripId: string) {
|
||||
return this.transitService.endTrip(tripId);
|
||||
}
|
||||
|
||||
@Post('tickets/buy')
|
||||
@RequiresFeature('transit')
|
||||
async buyTicket(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: { busTripId: string; price: number },
|
||||
) {
|
||||
return this.transitService.buyTicket(user.userId, body.busTripId, body.price);
|
||||
}
|
||||
|
||||
@Get('supervisor/:userId')
|
||||
@RequiresFeature('transit')
|
||||
@Roles('admin', 'dispatcher')
|
||||
async getSupervisorReports(@Param('userId') userId: string) {
|
||||
return this.transitService.getSupervisorReports(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TransitService } from './transit.service';
|
||||
import { TransitController } from './transit.controller';
|
||||
import { TransitBusListener } from './transit-bus-listener';
|
||||
import { RealtimeModule } from '../../realtime/realtime.module';
|
||||
|
||||
@Module({
|
||||
imports: [RealtimeModule],
|
||||
controllers: [TransitController],
|
||||
providers: [TransitService, TransitBusListener],
|
||||
exports: [TransitService],
|
||||
})
|
||||
export class TransitModule {}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface TransitRoute {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
polyline: any;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface TransitStation {
|
||||
id: string;
|
||||
route_id: string;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface TransitBusTrip {
|
||||
id: string;
|
||||
route_id: string;
|
||||
driver_id: string;
|
||||
status: string;
|
||||
current_station_id: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitService {
|
||||
private readonly logger = new Logger(TransitService.name);
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.baseUrl = this.config.get<string>('transit.serviceUrl') || 'http://localhost:4020';
|
||||
}
|
||||
|
||||
async getRoutes(tenantId: string): Promise<TransitRoute[]> {
|
||||
return this.get(`/transit/routes?tenant_id=${tenantId}`);
|
||||
}
|
||||
|
||||
async getStations(routeId: string): Promise<TransitStation[]> {
|
||||
return this.get(`/transit/routes/${routeId}/stations`);
|
||||
}
|
||||
|
||||
async startTrip(routeId: string, driverId: string): Promise<TransitBusTrip> {
|
||||
return this.post('/transit/trips/start', { route_id: routeId, driver_id: driverId });
|
||||
}
|
||||
|
||||
async endTrip(tripId: string): Promise<TransitBusTrip> {
|
||||
return this.post(`/transit/trips/${tripId}/end`, {});
|
||||
}
|
||||
|
||||
async buyTicket(passengerId: string, busTripId: string, price: number): Promise<any> {
|
||||
return this.post('/transit/tickets/buy', { passenger_id: passengerId, bus_trip_id: busTripId, price });
|
||||
}
|
||||
|
||||
async getSupervisorReports(userId: string): Promise<any> {
|
||||
return this.get(`/transit/reports/supervisor/${userId}`);
|
||||
}
|
||||
|
||||
private async get(path: string): Promise<any> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}${path}`);
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`transit GET ${path} failed: ${e?.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async post(path: string, body: any): Promise<any> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`transit POST ${path} failed: ${e?.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* مقطع صوتي مرفق برحلة (docs/22 — R2).
|
||||
*
|
||||
* التخزين على جهاز الراكب/السائق أولاً، ثم رفعه للسيرفر.
|
||||
* الجدول: tripz_trip_audio.
|
||||
*
|
||||
* الغرض: تسجيل صوتي للرحلة يُرفق في شكوى العملاء إن وُجدت.
|
||||
* كلا الطرفين يُبلَغان أن التسجيل جارٍ عند بدء الرحلة.
|
||||
*/
|
||||
@Entity('trip_audio')
|
||||
@Index(['tenant_id', 'trip_id'])
|
||||
export class TripAudio {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
tenant_id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
@Index()
|
||||
trip_id: string;
|
||||
|
||||
/** مَن رفع المقطع: rider أو driver. */
|
||||
@Column({ type: 'varchar' })
|
||||
uploaded_by: 'rider' | 'driver';
|
||||
|
||||
/** مفتاح التخزين (مسار نسبي في StorageService). */
|
||||
@Column({ type: 'varchar' })
|
||||
file_key: string;
|
||||
|
||||
/** الامتداد الأصلي للملف. */
|
||||
@Column({ type: 'varchar', default: 'm4a' })
|
||||
file_ext: string;
|
||||
|
||||
/** مدة المقطع بالثواني. */
|
||||
@Column({ type: 'int', nullable: true })
|
||||
duration_sec: number | null;
|
||||
|
||||
/** حجم المقطع بالبايت. */
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
file_size: number | null;
|
||||
|
||||
// ---- بيانات مساعدة لربط المقطع بالشكوى ----
|
||||
|
||||
/** اسم الوجهة النهائية (end_name) — يظهر في نموذج الشكوى. */
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
destination_name: string | null;
|
||||
|
||||
/** lat/lng نقطة الانطلاق. */
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
origin_lat: number | null;
|
||||
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
origin_lng: number | null;
|
||||
|
||||
/** lat/lng الوجهة النهائية. */
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
dest_lat: number | null;
|
||||
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
dest_lng: number | null;
|
||||
|
||||
/** حالة الرحلة وقت الرفع. */
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
trip_status_at_upload: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
uploaded_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TripsService } from './trips.service';
|
||||
|
||||
/**
|
||||
* ينهي الرحلات العالقة في `searching` بعد مهلة (docs/17 — R1).
|
||||
*
|
||||
* مكافئ `cron_ride_timeout` في سيرو (15 دقيقة افتراضياً). يعمل داخل
|
||||
* عملية الـAPI كـ`setInterval` — آمن مع عدة نسخ لأن `expireStuckSearches`
|
||||
* يستعمل UPDATE شرطياً (searching → no_drivers) فلا تُنهى رحلة مرتين.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchTimeoutSweeper implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger('SearchTimeout');
|
||||
private timer?: NodeJS.Timeout;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly trips: TripsService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const intervalMs = this.config.get<number>('trips.searchTimeoutSweepMs') ?? 60_000;
|
||||
this.timer = setInterval(() => void this.tick(), intervalMs);
|
||||
this.logger.log(`search-timeout sweeper up (every ${intervalMs}ms)`);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const timeoutMs = this.config.get<number>('trips.searchTimeoutMs') ?? 15 * 60_000;
|
||||
const n = await this.trips.expireStuckSearches(timeoutMs);
|
||||
if (n > 0) this.logger.log(`expired ${n} stuck searching trip(s)`);
|
||||
} catch (e: any) {
|
||||
this.logger.error(`sweep failed: ${e?.message}`);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TripAudio } from './entities/trip-audio.entity';
|
||||
import { StorageService } from '../../common/storage/storage.service';
|
||||
import { TripsService } from './trips.service';
|
||||
|
||||
/**
|
||||
* خدمة التسجيل الصوتي للرحلات (docs/22 — R2).
|
||||
*
|
||||
* التدفق:
|
||||
* 1. التطبيق (راكب/سائق) يسجّل الصوت أثناء الرحلة (in_progress).
|
||||
* 2. عند الإنهاء أو عند رفع الشبكة، يُرفع المقطع للسيرفر.
|
||||
* 3. السيرفر يحفظه ويربطه بالرحلة + بيانات الوجهة.
|
||||
* 4. إن وُجدت شكوى، يُرفق المقطع تلقائياً.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TripAudioService {
|
||||
private readonly logger = new Logger('TripAudio');
|
||||
|
||||
constructor(
|
||||
@InjectRepository(TripAudio)
|
||||
private readonly repo: Repository<TripAudio>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly trips: TripsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* رفع مقطع صوتي مرتبط برحلة.
|
||||
*
|
||||
* لا يشترط أن تكون الرحلة `in_progress` — قد يرفع السائق المقطع
|
||||
* بعد إنهاء الرحلة (التسجيل على الجهاز أولاً ثم الرفع لاحقاً).
|
||||
* لكن يشترط أن تكون الرحلة **ليست مجدولة** ولا **searching**.
|
||||
*/
|
||||
async upload(
|
||||
tenantId: string,
|
||||
tripId: string,
|
||||
userId: string,
|
||||
role: 'rider' | 'driver',
|
||||
file: { buffer: Buffer; originalname: string; mimetype: string; size: number },
|
||||
meta?: { duration_sec?: number; destination_name?: string },
|
||||
): Promise<TripAudio> {
|
||||
const trip = await this.trips.get(tenantId, tripId);
|
||||
if (!trip) throw new NotFoundException('Trip not found');
|
||||
|
||||
// لا تسجيل في رحلة مجدولة أو بحث — فقط from assigned فصاعداً.
|
||||
if (['scheduled', 'searching'].includes(trip.status)) {
|
||||
throw new BadRequestException('Cannot upload audio for a trip that has not started');
|
||||
}
|
||||
|
||||
// التحقق من أن الرافع هو طرف في الرحلة.
|
||||
if (role === 'rider' && trip.rider_id !== userId) {
|
||||
throw new ForbiddenException('Not a participant of this trip');
|
||||
}
|
||||
if (role === 'driver' && trip.driver_id !== userId) {
|
||||
throw new ForbiddenException('Not a participant of this trip');
|
||||
}
|
||||
|
||||
// حفظ الملف في التخزين.
|
||||
const ext = this.extFromMime(file.mimetype) || 'm4a';
|
||||
const key = await this.storage.save(
|
||||
tenantId,
|
||||
`audio/${tripId}`,
|
||||
file.buffer,
|
||||
`${role}-${Date.now()}.${ext}`,
|
||||
);
|
||||
|
||||
const audio = this.repo.create({
|
||||
tenant_id: tenantId,
|
||||
trip_id: tripId,
|
||||
uploaded_by: role,
|
||||
file_key: key,
|
||||
file_ext: ext,
|
||||
duration_sec: meta?.duration_sec ?? null,
|
||||
file_size: file.size,
|
||||
destination_name: meta?.destination_name ?? null,
|
||||
origin_lat: trip.origin_lat,
|
||||
origin_lng: trip.origin_lng,
|
||||
dest_lat: trip.dest_lat,
|
||||
dest_lng: trip.dest_lng,
|
||||
trip_status_at_upload: trip.status,
|
||||
});
|
||||
|
||||
const saved = await this.repo.save(audio);
|
||||
this.logger.log(
|
||||
`audio uploaded: trip=${tripId} by=${role} size=${file.size} dur=${meta?.duration_sec ?? '?'}s`,
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** كل المقاطع الصوتية لرحلة معينة. */
|
||||
async listByTrip(tenantId: string, tripId: string): Promise<TripAudio[]> {
|
||||
return this.repo.find({
|
||||
where: { tenant_id: tenantId, trip_id: tripId },
|
||||
order: { uploaded_at: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** مقطع واحد بالـID. */
|
||||
async findById(tenantId: string, id: string): Promise<TripAudio> {
|
||||
const audio = await this.repo.findOne({
|
||||
where: { tenant_id: tenantId, id },
|
||||
});
|
||||
if (!audio) throw new NotFoundException(`Audio ${id} not found`);
|
||||
return audio;
|
||||
}
|
||||
|
||||
/** رابط تحميل المقطع الصوتي. */
|
||||
async getDownloadUrl(tenantId: string, id: string): Promise<{ url: string }> {
|
||||
const audio = await this.findById(tenantId, id);
|
||||
return { url: this.storage.url(audio.file_key) };
|
||||
}
|
||||
|
||||
/** هل هذه الرحلة لها تسجيلات صوتية؟ (للشكوى). */
|
||||
async hasAudio(tenantId: string, tripId: string): Promise<boolean> {
|
||||
const count = await this.repo.count({
|
||||
where: { tenant_id: tenantId, trip_id: tripId },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
private extFromMime(mime: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'audio/m4a': 'm4a',
|
||||
'audio/x-m4a': 'm4a',
|
||||
'audio/mp4': 'm4a',
|
||||
'audio/aac': 'aac',
|
||||
'audio/ogg': 'ogg',
|
||||
'audio/webm': 'webm',
|
||||
'audio/wav': 'wav',
|
||||
'audio/x-wav': 'wav',
|
||||
};
|
||||
return map[mime] ?? 'm4a';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { TripsService, RequestTripDto } from './trips.service';
|
||||
import { TripAudioService } from './trip-audio.service';
|
||||
import { TripStatus } from './entities/trip.entity';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
@@ -12,7 +14,10 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('trips')
|
||||
export class TripsController {
|
||||
constructor(private readonly trips: TripsService) {}
|
||||
constructor(
|
||||
private readonly trips: TripsService,
|
||||
private readonly audio: TripAudioService,
|
||||
) {}
|
||||
|
||||
/** سرد رحلات المستأجر — للوحة الأدمن، بترشيح الحالة (docs/22 — N4). */
|
||||
@UseGuards(RolesGuard)
|
||||
@@ -70,4 +75,39 @@ export class TripsController {
|
||||
const actor = user.role === 'driver' ? 'driver' : 'rider';
|
||||
return this.trips.cancel(user.tenantId, id, actor, user.userId);
|
||||
}
|
||||
|
||||
// ---- التسجيل الصوتي (R2) ----
|
||||
|
||||
/** رفع مقطع صوتي مرتبط بالرحلة. */
|
||||
@Post(':id/audio')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 50 * 1024 * 1024 } }))
|
||||
uploadAudio(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: any,
|
||||
@Body('duration_sec') durationSec?: string,
|
||||
@Body('destination_name') destName?: string,
|
||||
) {
|
||||
const role = user.role === 'driver' ? 'driver' as const : 'rider' as const;
|
||||
return this.audio.upload(user.tenantId, id, user.userId, role, file, {
|
||||
duration_sec: durationSec ? parseInt(durationSec, 10) : undefined,
|
||||
destination_name: destName,
|
||||
});
|
||||
}
|
||||
|
||||
/** كل المقاطع الصوتية للرحلة. */
|
||||
@Get(':id/audio')
|
||||
listAudio(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.audio.listByTrip(user.tenantId, id);
|
||||
}
|
||||
|
||||
/** رابط تحميل مقطع صوتي. */
|
||||
@Get(':id/audio/:audioId')
|
||||
getAudioUrl(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Param('audioId') audioId: string,
|
||||
) {
|
||||
return this.audio.getDownloadUrl(user.tenantId, audioId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Trip } from './entities/trip.entity';
|
||||
import { TripEvent } from './entities/trip-event.entity';
|
||||
import { TripAudio } from './entities/trip-audio.entity';
|
||||
import { TripsService } from './trips.service';
|
||||
import { ScheduledTripsSweeper } from './scheduled-trips.sweeper';
|
||||
import { TripStateService } from './trip-state.service';
|
||||
import { TripAudioService } from './trip-audio.service';
|
||||
import { TripsController } from './trips.controller';
|
||||
import { MapsModule } from '../maps/maps.module';
|
||||
import { TariffModule } from '../tariff/tariff.module';
|
||||
@@ -23,7 +24,7 @@ import { DriverTrack } from '../locations/entities/driver-track.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Trip, TripEvent, DriverTrack]),
|
||||
TypeOrmModule.forFeature([Trip, TripEvent, TripAudio, DriverTrack]),
|
||||
MapsModule,
|
||||
TariffModule,
|
||||
MatchingModule,
|
||||
@@ -38,7 +39,7 @@ import { DriverTrack } from '../locations/entities/driver-track.entity';
|
||||
RewardsModule,
|
||||
],
|
||||
controllers: [TripsController],
|
||||
providers: [TripsService, TripStateService, ScheduledTripsSweeper, TripDistanceService],
|
||||
providers: [TripsService, TripStateService, TripDistanceService, TripAudioService],
|
||||
exports: [TripsService],
|
||||
})
|
||||
export class TripsModule {}
|
||||
|
||||
@@ -68,6 +68,8 @@ import { GeofenceService } from '../geofence/geofence.service';
|
||||
import { CouponsService } from '../rewards/coupons.service';
|
||||
import { ReferralsService } from '../rewards/referrals.service';
|
||||
import { TripDistanceService } from './trip-distance.service';
|
||||
import { SurgeService } from '../tariff/surge.service';
|
||||
import { TierCalculatorService } from '../drivers/tier-calculator.service';
|
||||
|
||||
@Injectable()
|
||||
export class TripsService {
|
||||
@@ -92,6 +94,8 @@ export class TripsService {
|
||||
private readonly coupons: CouponsService,
|
||||
private readonly referrals: ReferralsService,
|
||||
private readonly distances: TripDistanceService,
|
||||
private readonly surge: SurgeService,
|
||||
private readonly tierCalc: TierCalculatorService,
|
||||
) {}
|
||||
|
||||
get(tenantId: string, id: string): Promise<Trip | null> {
|
||||
@@ -317,6 +321,53 @@ export class TripsService {
|
||||
return released;
|
||||
}
|
||||
|
||||
/**
|
||||
* إنهاء الرحلات العالقة في `searching` بعد مهلة (docs/17 — R1).
|
||||
*
|
||||
* سيرو يستخدم `cron_ride_timeout` بـ15 دقيقة: أي رحلة بحث لم يقبلها
|
||||
* سائق خلال المهلة تنتقل إلى `no_drivers` وتُنظَّف من Redis. يمنع
|
||||
* الرحلات من التعليق إلى الأبد عند عدم توفر سائقين.
|
||||
*
|
||||
* آمن مع عدة نسخ: `UPDATE ... WHERE status = 'searching'` يضمن ألّا
|
||||
* تُنهى رحلة مرتين أو تنتقل وهي في حالة أخرى.
|
||||
*/
|
||||
async expireStuckSearches(timeoutMs: number): Promise<number> {
|
||||
const deadline = new Date(Date.now() - timeoutMs);
|
||||
const candidates = await this.trips.find({
|
||||
where: { status: 'searching' as TripStatus, requested_at: LessThanOrEqual(deadline) },
|
||||
order: { requested_at: 'ASC' },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
for (const trip of candidates) {
|
||||
// UPDATE شرطي: لا تنتقل إلا إذا كانت لا تزال searching.
|
||||
const res = await this.trips.update(
|
||||
{ tenant_id: trip.tenant_id, id: trip.id, status: 'searching' },
|
||||
{ status: 'no_drivers' },
|
||||
);
|
||||
if (!res.affected) continue;
|
||||
|
||||
await this.state.clear(trip.tenant_id, trip.id);
|
||||
await this.recordEvent(trip, 'searching', 'no_drivers', 'sweeper');
|
||||
|
||||
// إشعار الراكب بأن لا سائقين متاحين.
|
||||
await this.notifyParties(trip.tenant_id, trip, 'no_drivers', null);
|
||||
|
||||
try {
|
||||
await this.surge.incrementDemand(trip.tenant_id, trip.city ?? 'default', trip.service_class);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`surge incrementDemand failed for expired trip: ${e?.message}`);
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`trip ${trip.id} expired after ${(Date.now() - trip.requested_at.getTime()) / 1000}s in searching`,
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** قبول سائق للرحلة (أول قبول يفوز — docs/17 A4). */
|
||||
async accept(tenantId: string, tripId: string, driverUserId: string) {
|
||||
const driver = await this.drivers.findByUser(tenantId, driverUserId);
|
||||
@@ -480,6 +531,17 @@ export class TripsService {
|
||||
}
|
||||
}
|
||||
|
||||
// تحديث مقاييس السائق عند إنهاء الرحلة (شرائح M2):
|
||||
// total_trips + إعادة حساب الشريحة.
|
||||
if (toStatus === 'completed' && snapshot.driver_id) {
|
||||
try {
|
||||
await this.drivers.incrementTripCount(tenantId, snapshot.driver_id);
|
||||
await this.tierCalc.updateDriverTier(snapshot.driver_id);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`driver tier update failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// تسوية المحفظة عند الدفع (payment_method === wallet): المبلغ ينتقل من
|
||||
// الراكب للسائق **كاملاً**. العمولة لا تُقتطع هنا — خُصمت من الرصيد
|
||||
// التشغيلي عند الإنهاء (docs/18 §5.5: قاعدة واحدة للكاش والمحفظة).
|
||||
@@ -506,6 +568,20 @@ export class TripsService {
|
||||
}
|
||||
|
||||
await this.notifyParties(tenantId, trip, toStatus, snapshot.driver_user_id);
|
||||
|
||||
// R2 — إشعار الطرفين بأن التسجيل الصوتي جارٍ عند بدء الرحلة.
|
||||
if (toStatus === 'in_progress') {
|
||||
const targets = [trip.rider_id, ...(snapshot.driver_user_id ? [snapshot.driver_user_id] : [])];
|
||||
await this.notifications.sendLocalizedToMany(
|
||||
tenantId,
|
||||
targets,
|
||||
'trip.audio_recording_started',
|
||||
{},
|
||||
{ type: 'trip_audio_recording', tripId: trip.id },
|
||||
{ dataOnly: true },
|
||||
).catch((e: any) => this.logger.warn(`audio recording notification failed: ${e?.message}`));
|
||||
}
|
||||
|
||||
return trip;
|
||||
}
|
||||
|
||||
@@ -553,6 +629,14 @@ export class TripsService {
|
||||
await this.cancelLosingOffers(tenantId, tripId, actorUserId);
|
||||
}
|
||||
|
||||
if (snapshot.status === 'searching') {
|
||||
try {
|
||||
await this.surge.incrementDemand(tenantId, trip.city ?? 'default', trip.service_class);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`surge incrementDemand failed: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.notifyParties(tenantId, trip, 'cancelled', snapshot.driver_user_id, {
|
||||
fee,
|
||||
currency: trip.currency ?? '',
|
||||
|
||||
Reference in New Issue
Block a user