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_PASSWORD_EGYPT=
|
||||||
SMS_SENDER=
|
SMS_SENDER=
|
||||||
|
|
||||||
# ---- FCM push (اتركه فارغاً لتعطيل الإرسال) ----
|
# ---- FCM push (ملف الـ JSON الخاص بـ Service Account) ----
|
||||||
FCM_SERVER_KEY=
|
GOOGLE_APPLICATION_CREDENTIALS=./firebase-service-account.json
|
||||||
FCM_ENDPOINT=https://fcm.googleapis.com/fcm/send
|
|
||||||
|
|
||||||
# ---- Gemini (رؤية: قراءة وثائق + مطابقة وجه + تحليل رسائل التحويل P2) ----
|
# ---- Gemini (رؤية: قراءة وثائق + مطابقة وجه + تحليل رسائل التحويل P2) ----
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
GEMINI_MODEL=gemini-2.0-flash
|
GEMINI_MODEL=gemini-2.0-flash
|
||||||
@@ -80,5 +78,36 @@ PAYMOB_INTEGRATION_ID_DRIVER_CARD=
|
|||||||
PAYMOB_INTEGRATION_ID_DRIVER_WALLET=
|
PAYMOB_INTEGRATION_ID_DRIVER_WALLET=
|
||||||
PAYMOB_IFRAME_ID=
|
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 ----
|
# ---- سرّ المنصة (لتعيين أول أدمن) — ولّده: openssl rand -hex 24 ----
|
||||||
PLATFORM_SECRET=
|
PLATFORM_SECRET=
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ services:
|
|||||||
# اللوحات الثلاث — bind للقراءة فقط، لا نسخ داخل الصورة: تعديل HTML
|
# اللوحات الثلاث — bind للقراءة فقط، لا نسخ داخل الصورة: تعديل HTML
|
||||||
# يظهر بتحديث الصفحة بلا إعادة بناء (اللوحات ملفات ساكنة بلا خطوة بناء).
|
# يظهر بتحديث الصفحة بلا إعادة بناء (اللوحات ملفات ساكنة بلا خطوة بناء).
|
||||||
- ../dashboards:/app/dashboards:ro
|
- ../dashboards:/app/dashboards:ro
|
||||||
|
- ./firebase-service-account.json:/app/firebase-service-account.json:ro
|
||||||
networks: [tripz-net]
|
networks: [tripz-net]
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
|
|||||||
@@ -41,7 +41,8 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
"typeorm": "^0.3.20"
|
"typeorm": "^0.3.20",
|
||||||
|
"firebase-admin": "^12.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
|||||||
import { GeminiModule } from './integrations/gemini/gemini.module';
|
import { GeminiModule } from './integrations/gemini/gemini.module';
|
||||||
import { BillingModule } from './modules/billing/billing.module';
|
import { BillingModule } from './modules/billing/billing.module';
|
||||||
import { GeofenceModule } from './modules/geofence/geofence.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -111,6 +116,11 @@ import { GeofenceModule } from './modules/geofence/geofence.module';
|
|||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
SeedModule,
|
SeedModule,
|
||||||
|
CatalogModule,
|
||||||
|
CronModule, // O5 — المهام الدورية الموحّدة على BullMQ
|
||||||
|
MarketingModule, // O3 — محرك التسويق التلقائي
|
||||||
|
TransitModule, // O2 — نظام المواصلات
|
||||||
|
BotModule, // O4 — بوتات التواصل الاجتماعي
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// ThrottlerModule.forRoot() وحده لا يفعل شيئاً — كان مسجَّلاً منذ البداية
|
// 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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@ export const FEATURES = [
|
|||||||
'ads', // الدعاية
|
'ads', // الدعاية
|
||||||
'transit', // نظام المواصلات
|
'transit', // نظام المواصلات
|
||||||
'api_access', // API + Webhooks للمستأجر
|
'api_access', // API + Webhooks للمستأجر
|
||||||
|
'driver_tiers', // مستويات السائقين (برونزي/فضي/ذهبي/بلاتيني)
|
||||||
|
'marketing_engine', // محرك التسويق التلقائي
|
||||||
|
'dynamic_pricing', // التسعير الديناميكي (مضاعف الطلب)
|
||||||
|
'geofence', // السياج الجغرافي — مناطق التسعير والتنبيهات
|
||||||
|
'negotiator', // المفاوض الآلي — تفاوض السعر تلقائياً
|
||||||
|
'driver_assurance', // تأمين السائق — تغطية تأمينية
|
||||||
|
'coupons', // كوبونات الخصم
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Feature = (typeof FEATURES)[number];
|
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 },
|
limits: { drivers_max: 50, cities_max: 1 },
|
||||||
},
|
},
|
||||||
brand: {
|
brand: {
|
||||||
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments'],
|
features: ['wallet', 'chat', 'calls', 'ride_types', 'payments', 'driver_tiers'],
|
||||||
limits: { drivers_max: 500, cities_max: 3 },
|
limits: { drivers_max: 500, cities_max: 3 },
|
||||||
},
|
},
|
||||||
fleet: {
|
fleet: {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const ar: Catalog = {
|
|||||||
'trip.expired': { title: 'انتهت مهلة الطلب', body: 'انتهت مهلة طلب الرحلة دون قبول' },
|
'trip.expired': { title: 'انتهت مهلة الطلب', body: 'انتهت مهلة طلب الرحلة دون قبول' },
|
||||||
'trip.offer': { title: 'طلب رحلة جديد', body: 'راكب على بعد {distanceKm} كم — الأجرة {fare} {currency}' },
|
'trip.offer': { title: 'طلب رحلة جديد', body: 'راكب على بعد {distanceKm} كم — الأجرة {fare} {currency}' },
|
||||||
'trip.offer_taken': { title: 'الرحلة لم تعد متاحة', body: 'قبِل الطلبَ سائق آخر' },
|
'trip.offer_taken': { title: 'الرحلة لم تعد متاحة', body: 'قبِل الطلبَ سائق آخر' },
|
||||||
|
'trip.audio_recording_started': { title: 'بدء التسجيل', body: 'تم بدء تسجيل الرحلة صوتياً لضمان سلامتكما' },
|
||||||
'chat.message': { title: 'رسالة جديدة', body: '{preview}' },
|
'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.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': { 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.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}' },
|
'chat.message': { title: 'New message', body: '{preview}' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ export default () => ({
|
|||||||
// الرحلات: كل كم يفحص كنس الرحلات المجدولة (docs/17 — B5).
|
// الرحلات: كل كم يفحص كنس الرحلات المجدولة (docs/17 — B5).
|
||||||
trips: {
|
trips: {
|
||||||
scheduleSweepMs: parseInt(process.env.TRIP_SCHEDULE_SWEEP_MS ?? '30000', 10),
|
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).
|
// المكافآت: كل كم يمسح الماسح الإحالات المؤهَّلة غير المصروفة (docs/17 — L5).
|
||||||
@@ -115,15 +125,19 @@ export default () => ({
|
|||||||
baseUrl: process.env.STORAGE_BASE_URL ?? '',
|
baseUrl: process.env.STORAGE_BASE_URL ?? '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// خدمة المواصلات (microservice مستقل)
|
||||||
|
transit: {
|
||||||
|
serviceUrl: process.env.TRANSIT_SERVICE_URL ?? 'http://transit:4020',
|
||||||
|
},
|
||||||
|
|
||||||
// المدفوعات: توقيع HMAC على العمليات المالية (docs/17 — I6).
|
// المدفوعات: توقيع HMAC على العمليات المالية (docs/17 — I6).
|
||||||
// مطفأ حتى يوقّع تطبيق فلاتر؛ تفعيله قبل ذلك يقطع كل سحب.
|
// مطفأ حتى يوقّع تطبيق فلاتر؛ تفعيله قبل ذلك يقطع كل سحب.
|
||||||
payments: {
|
payments: {
|
||||||
requireSignature: process.env.PAYMENTS_REQUIRE_SIGNATURE === 'true',
|
requireSignature: process.env.PAYMENTS_REQUIRE_SIGNATURE === 'true',
|
||||||
},
|
},
|
||||||
|
|
||||||
// إشعارات FCM (اتركه فارغاً لتعطيل الإرسال — يُسجَّل فقط).
|
// إشعارات FCM (ملف JSON لحزمة firebase-admin).
|
||||||
fcm: {
|
fcm: {
|
||||||
serverKey: process.env.FCM_SERVER_KEY ?? '',
|
credentialsPath: process.env.GOOGLE_APPLICATION_CREDENTIALS ?? '',
|
||||||
endpoint: process.env.FCM_ENDPOINT ?? 'https://fcm.googleapis.com/fcm/send',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||||
import { CashAdapter } from './cash.adapter';
|
import { CashAdapter } from './cash.adapter';
|
||||||
import { PaymobAdapter } from './paymob.adapter';
|
import { PaymobAdapter } from './paymob.adapter';
|
||||||
|
import { SyriatelAdapter } from './syriatel.adapter';
|
||||||
|
import { MtnAdapter } from './mtn.adapter';
|
||||||
|
|
||||||
describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)', () => {
|
describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)', () => {
|
||||||
it('يولّد مرجعاً ويعرض حساب الاستلام المضبوط للمستأجر', async () => {
|
it('يولّد مرجعاً ويعرض حساب الاستلام المضبوط للمستأجر', async () => {
|
||||||
@@ -45,7 +47,12 @@ describe('InvoiceAdapter — كليك/شام كاش/MTN بلا API (docs/24 §5)
|
|||||||
|
|
||||||
describe('PaymentGatewayRegistry — يربط الكتالوج بمحوّله', () => {
|
describe('PaymentGatewayRegistry — يربط الكتالوج بمحوّله', () => {
|
||||||
const config = { get: () => undefined } as any;
|
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('يعرف كل مزوّدي الكتالوج', () => {
|
it('يعرف كل مزوّدي الكتالوج', () => {
|
||||||
const r = registry();
|
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 { CashAdapter } from './cash.adapter';
|
||||||
import { PaymobAdapter } from './paymob.adapter';
|
import { PaymobAdapter } from './paymob.adapter';
|
||||||
import { InvoiceAdapter } from './invoice.adapter';
|
import { InvoiceAdapter } from './invoice.adapter';
|
||||||
|
import { SyriatelAdapter } from './syriatel.adapter';
|
||||||
|
import { MtnAdapter } from './mtn.adapter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* يربط اسم المزوّد (من كتالوج `payment-methods.ts`) بمحوّله (docs/07).
|
* يربط اسم المزوّد (من كتالوج `payment-methods.ts`) بمحوّله (docs/07).
|
||||||
@@ -13,11 +15,18 @@ import { InvoiceAdapter } from './invoice.adapter';
|
|||||||
export class PaymentGatewayRegistry {
|
export class PaymentGatewayRegistry {
|
||||||
private readonly adapters = new Map<string, PaymentAdapter>();
|
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(cash.name, cash);
|
||||||
this.adapters.set(paymob.name, paymob);
|
this.adapters.set(paymob.name, paymob);
|
||||||
// بلا API فعلية اليوم — محوّل مشترك واحد لكل منها (invoice.adapter.ts).
|
this.adapters.set(syriatel.name, syriatel);
|
||||||
for (const provider of ['cliq', 'shamcash', 'mtn', 'syriatel', 'zaincash']) {
|
this.adapters.set(mtn.name, mtn);
|
||||||
|
// CliQ وشام كاش وزين كاش — فواتير (بلا API فعلية)
|
||||||
|
for (const provider of ['cliq', 'shamcash', 'zaincash']) {
|
||||||
this.adapters.set(provider, new InvoiceAdapter(provider));
|
this.adapters.set(provider, new InvoiceAdapter(provider));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CashAdapter } from './cash.adapter';
|
import { CashAdapter } from './cash.adapter';
|
||||||
import { PaymobAdapter } from './paymob.adapter';
|
import { PaymobAdapter } from './paymob.adapter';
|
||||||
|
import { SyriatelAdapter } from './syriatel.adapter';
|
||||||
|
import { MtnAdapter } from './mtn.adapter';
|
||||||
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
import { PaymentGatewayRegistry } from './payment-gateway.registry';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [CashAdapter, PaymobAdapter, PaymentGatewayRegistry],
|
providers: [CashAdapter, PaymobAdapter, SyriatelAdapter, MtnAdapter, PaymentGatewayRegistry],
|
||||||
exports: [PaymentGatewayRegistry],
|
exports: [PaymentGatewayRegistry],
|
||||||
})
|
})
|
||||||
export class PaymentGatewaysModule {}
|
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 { Driver } from './entities/driver.entity';
|
||||||
import { DriversService } from './drivers.service';
|
import { DriversService } from './drivers.service';
|
||||||
import { DriversController } from './drivers.controller';
|
import { DriversController } from './drivers.controller';
|
||||||
|
import { AdminTierController } from './admin-tier.controller';
|
||||||
|
import { TierCalculatorService } from './tier-calculator.service';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { LocationsModule } from '../locations/locations.module';
|
import { LocationsModule } from '../locations/locations.module';
|
||||||
import { CreditModule } from '../credit/credit.module';
|
import { CreditModule } from '../credit/credit.module';
|
||||||
@@ -14,8 +16,8 @@ import { CreditModule } from '../credit/credit.module';
|
|||||||
LocationsModule,
|
LocationsModule,
|
||||||
forwardRef(() => CreditModule),
|
forwardRef(() => CreditModule),
|
||||||
],
|
],
|
||||||
controllers: [DriversController],
|
controllers: [DriversController, AdminTierController],
|
||||||
providers: [DriversService],
|
providers: [DriversService, TierCalculatorService],
|
||||||
exports: [DriversService],
|
exports: [DriversService, TierCalculatorService],
|
||||||
})
|
})
|
||||||
export class DriversModule {}
|
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> {
|
async approve(tenantId: string, driverId: string): Promise<Driver> {
|
||||||
const driver = await this.findById(tenantId, driverId);
|
const driver = await this.findById(tenantId, driverId);
|
||||||
if (!driver) throw new NotFoundException('Driver not found');
|
if (!driver) throw new NotFoundException('Driver not found');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
|
||||||
|
|
||||||
export type VerificationStatus = 'pending' | 'approved' | 'rejected';
|
export type VerificationStatus = 'pending' | 'approved' | 'rejected';
|
||||||
|
export type DriverTier = 'bronze' | 'silver' | 'gold' | 'platinum';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* السائق = مستخدم بدور driver + بيانات مركبة ووثائق. الجدول: tripz_drivers.
|
* السائق = مستخدم بدور driver + بيانات مركبة ووثائق. الجدول: tripz_drivers.
|
||||||
@@ -54,6 +55,18 @@ export class Driver {
|
|||||||
@Column({ type: 'numeric', precision: 3, scale: 2, default: 5 })
|
@Column({ type: 'numeric', precision: 3, scale: 2, default: 5 })
|
||||||
rating: number;
|
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` عند سيرو) ----
|
// ---- لقطة الموقع (مكافئ `car_locations` عند سيرو) ----
|
||||||
// الموقع الحيّ يعيش في Redis؛ هذه الأعمدة **يكتبها الـworker دورياً فقط**،
|
// الموقع الحيّ يعيش في Redis؛ هذه الأعمدة **يكتبها الـworker دورياً فقط**،
|
||||||
// لا الطلب. لا تقرأها للحيّ — استخدم DriverLocationService (docs/17 — H1/H3).
|
// لا الطلب. لا تقرأها للحيّ — استخدم 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 { CampaignLog } from './entities/campaign-log.entity';
|
||||||
import { GeofenceService } from './geofence.service';
|
import { GeofenceService } from './geofence.service';
|
||||||
import { GeofenceController } from './geofence.controller';
|
import { GeofenceController } from './geofence.controller';
|
||||||
|
import { GeofenceAdminController } from './geofence-admin.controller';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -12,7 +13,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
providers: [GeofenceService],
|
providers: [GeofenceService],
|
||||||
controllers: [GeofenceController],
|
controllers: [GeofenceController, GeofenceAdminController],
|
||||||
exports: [GeofenceService],
|
exports: [GeofenceService],
|
||||||
})
|
})
|
||||||
export class GeofenceModule {}
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { GeofenceZone } from './entities/geofence-zone.entity';
|
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));
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
return R * c;
|
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 { DriverTrack } from './entities/driver-track.entity';
|
||||||
import { DriverLocationService } from './driver-location.service';
|
import { DriverLocationService } from './driver-location.service';
|
||||||
import { LocationFlusherService } from './location-flusher.service';
|
import { LocationFlusherService } from './location-flusher.service';
|
||||||
|
import { HeatmapService } from './heatmap.service';
|
||||||
|
import { HeatmapController } from './heatmap.controller';
|
||||||
import { MatchingModule } from '../matching/matching.module';
|
import { MatchingModule } from '../matching/matching.module';
|
||||||
import { GeofenceModule } from '../geofence/geofence.module';
|
import { GeofenceModule } from '../geofence/geofence.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* وحدة مستقلة عمداً: تستورد MatchingModule، وتستوردها DriversModule و
|
* وحدة المستخدمات — موقع + خرائط حرارية.
|
||||||
* TripsModule — لو عاشت داخل drivers لصارت دورة استيراد مع matching.
|
* تستورد MatchingModule وGeofenceModule، وتستوردها DriversModule وTripsModule.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Driver, DriverTrack]), MatchingModule, GeofenceModule],
|
imports: [TypeOrmModule.forFeature([Driver, DriverTrack]), MatchingModule, GeofenceModule],
|
||||||
providers: [DriverLocationService, LocationFlusherService],
|
providers: [DriverLocationService, LocationFlusherService, HeatmapService],
|
||||||
exports: [DriverLocationService, LocationFlusherService],
|
controllers: [HeatmapController],
|
||||||
|
exports: [DriverLocationService, LocationFlusherService, HeatmapService],
|
||||||
})
|
})
|
||||||
export class LocationsModule {}
|
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 { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { MatchingService } from './matching.service';
|
import { MatchingService } from './matching.service';
|
||||||
|
import { Driver } from '../drivers/entities/driver.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Driver])],
|
||||||
providers: [MatchingService],
|
providers: [MatchingService],
|
||||||
exports: [MatchingService],
|
exports: [MatchingService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,35 +1,38 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { REDIS } from '../../common/redis/redis.module';
|
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 {
|
export interface NearbyDriver {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
distanceKm: number;
|
distanceKm: number;
|
||||||
|
tier?: DriverTier;
|
||||||
|
tierScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** توفّر السائق — يحدّد الفهرس الذي يعيش فيه (docs/17 — H5). */
|
|
||||||
export type DriverAvailability = 'available' | 'busy' | 'off';
|
export type DriverAvailability = 'available' | 'busy' | 'off';
|
||||||
|
|
||||||
/**
|
const TIER_PRIORITY: Record<DriverTier, number> = {
|
||||||
* المطابقة الجغرافية عبر Redis GEO (docs/09). مفتاح لكل
|
platinum: 4,
|
||||||
* (مستأجر × فئة خدمة × توفّر): geo:drivers:{tenantId}:{class}:{available|busy}
|
gold: 3,
|
||||||
* (ببادئة tripz: تلقائياً من ioredis).
|
silver: 2,
|
||||||
*
|
bronze: 1,
|
||||||
* فصل available عن busy (نمط سيرو): البحث يمسح المتاحين فقط بدل أن يجلب
|
};
|
||||||
* الجميع ثم يصفّي المشغولين.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MatchingService {
|
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 {
|
private key(tenantId: string, serviceClass: string, status: 'available' | 'busy'): string {
|
||||||
return `geo:drivers:${tenantId}:${serviceClass}:${status}`;
|
return `geo:drivers:${tenantId}:${serviceClass}:${status}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* يضع السائق في الفهرس الموافق لحالته ويزيله من الآخر — عملية واحدة
|
|
||||||
* تمنع بقاءه في الفهرسين معاً.
|
|
||||||
*/
|
|
||||||
async setPosition(
|
async setPosition(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
serviceClass: string,
|
serviceClass: string,
|
||||||
@@ -59,13 +62,12 @@ export class MatchingService {
|
|||||||
.exec();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** أقرب السائقين **المتاحين** من نفس فئة الخدمة ضمن نصف قطر (كم). */
|
|
||||||
async findNearby(
|
async findNearby(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
serviceClass: string,
|
serviceClass: string,
|
||||||
lat: number,
|
lat: number,
|
||||||
lng: number,
|
lng: number,
|
||||||
radiusKm = 5,
|
radiusKm = 3,
|
||||||
count = 10,
|
count = 10,
|
||||||
): Promise<NearbyDriver[]> {
|
): Promise<NearbyDriver[]> {
|
||||||
const res = (await this.redis.call(
|
const res = (await this.redis.call(
|
||||||
@@ -79,14 +81,35 @@ export class MatchingService {
|
|||||||
'km',
|
'km',
|
||||||
'ASC',
|
'ASC',
|
||||||
'COUNT',
|
'COUNT',
|
||||||
String(count),
|
String(count * 2),
|
||||||
'WITHDIST',
|
'WITHDIST',
|
||||||
)) as [string, string][];
|
)) as [string, string][];
|
||||||
|
|
||||||
if (!Array.isArray(res)) return [];
|
if (!Array.isArray(res) || res.length === 0) return [];
|
||||||
return res.map(([driverId, dist]) => ({
|
|
||||||
|
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,
|
driverId,
|
||||||
distanceKm: Number(Number(dist).toFixed(3)),
|
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 { I18nService } from '../../common/i18n/i18n.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
import { CacheService, CacheKeys, TTL } from '../../common/cache/cache.service';
|
||||||
|
import * as admin from 'firebase-admin';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
export interface PushOptions {
|
export interface PushOptions {
|
||||||
/** رسالة بيانات فقط — لا يعرضها النظام، يتولّاها التطبيق (overlay أندرويد، docs/17 A7). */
|
/** رسالة بيانات فقط — لا يعرضها النظام، يتولّاها التطبيق (overlay أندرويد، docs/17 A7). */
|
||||||
@@ -24,7 +26,7 @@ interface CachedToken {
|
|||||||
* بلا FCM_SERVER_KEY: يُسجَّل فقط دون إرسال.
|
* بلا FCM_SERVER_KEY: يُسجَّل فقط دون إرسال.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService implements OnModuleInit {
|
||||||
private readonly logger = new Logger('Notifications');
|
private readonly logger = new Logger('Notifications');
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -35,6 +37,23 @@ export class NotificationsService {
|
|||||||
private readonly cache: CacheService,
|
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') {
|
async register(tenantId: string, userId: string, token: string, platform = 'android') {
|
||||||
const existing = await this.tokens.findOne({ where: { token } });
|
const existing = await this.tokens.findOne({ where: { token } });
|
||||||
let saved: DeviceToken;
|
let saved: DeviceToken;
|
||||||
@@ -137,40 +156,42 @@ export class NotificationsService {
|
|||||||
data: Record<string, any>,
|
data: Record<string, any>,
|
||||||
opts: PushOptions,
|
opts: PushOptions,
|
||||||
) {
|
) {
|
||||||
const key = this.config.get<string>('fcm.serverKey');
|
if (admin.apps.length === 0 || rows.length === 0) {
|
||||||
if (!key || rows.length === 0) {
|
this.logger.debug(`push skip (adminInitialized=${admin.apps.length > 0} tokens=${rows.length}) "${title}"`);
|
||||||
this.logger.debug(`push skip (key=${!!key} tokens=${rows.length}) "${title}"`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const endpoint = this.config.get<string>('fcm.endpoint')!;
|
|
||||||
// FCM يقبل نصوصاً فقط في data — والـoverlay يقرأ العنوان/النص من هنا.
|
// FCM يقبل نصوصاً فقط في data — والـoverlay يقرأ العنوان/النص من هنا.
|
||||||
const payload = this.stringifyData({ ...data, title, body });
|
const payload = this.stringifyData({ ...data, title, body });
|
||||||
|
const tokens = rows.map((r) => 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[] = [];
|
const stale: string[] = [];
|
||||||
await Promise.all(
|
res.responses.forEach((resp, idx) => {
|
||||||
rows.map(async (r) => {
|
if (!resp.success) {
|
||||||
try {
|
const errCode = resp.error?.code;
|
||||||
const res = await fetch(endpoint, {
|
if (errCode === 'messaging/invalid-registration-token' ||
|
||||||
method: 'POST',
|
errCode === 'messaging/registration-token-not-registered') {
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `key=${key}` },
|
stale.push(tokens[idx]);
|
||||||
body: JSON.stringify({
|
} else {
|
||||||
to: r.token,
|
this.logger.warn(`push failed for token ${tokens[idx]}: ${resp.error?.message}`);
|
||||||
// أولوية عالية: يوقظ التطبيق في الخلفية (شرط الـ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);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
this.logger.warn(`push failed: ${e?.message}`);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// توكن ميت = إرسال ضائع لكل رحلة لاحقة؛ نحذفه فور ما يخبرنا FCM.
|
// توكن ميت = إرسال ضائع لكل رحلة لاحقة؛ نحذفه فور ما يخبرنا FCM.
|
||||||
// والكاش يُبطَل معه، وإلا بقي التوكن الميت يُستعمل حتى انتهاء عمر المفتاح.
|
// والكاش يُبطَل معه، وإلا بقي التوكن الميت يُستعمل حتى انتهاء عمر المفتاح.
|
||||||
@@ -179,6 +200,9 @@ export class NotificationsService {
|
|||||||
await this.cache.del(CacheKeys.deviceTokens(tenantId, userId));
|
await this.cache.del(CacheKeys.deviceTokens(tenantId, userId));
|
||||||
this.logger.debug(`removed ${stale.length} stale token(s)`);
|
this.logger.debug(`removed ${stale.length} stale token(s)`);
|
||||||
}
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`push batch failed: ${e?.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private stringifyData(data: Record<string, any>): Record<string, string> {
|
private stringifyData(data: Record<string, any>): Record<string, string> {
|
||||||
|
|||||||
@@ -272,4 +272,23 @@ export class ReferralsService {
|
|||||||
ref: `${referral.id}:${side}`,
|
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 { CouponsService } from './coupons.service';
|
||||||
import { ReferralsService } from './referrals.service';
|
import { ReferralsService } from './referrals.service';
|
||||||
import { RewardsController } from './rewards.controller';
|
import { RewardsController } from './rewards.controller';
|
||||||
import { RewardsSweeper } from './rewards.sweeper';
|
|
||||||
import { CreditModule } from '../credit/credit.module';
|
import { CreditModule } from '../credit/credit.module';
|
||||||
import { DriversModule } from '../drivers/drivers.module';
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
import { TenantsModule } from '../tenants/tenants.module';
|
import { TenantsModule } from '../tenants/tenants.module';
|
||||||
@@ -27,7 +26,7 @@ import { TenantsModule } from '../tenants/tenants.module';
|
|||||||
TenantsModule,
|
TenantsModule,
|
||||||
],
|
],
|
||||||
controllers: [RewardsController],
|
controllers: [RewardsController],
|
||||||
providers: [CouponsService, ReferralsService, RewardsSweeper],
|
providers: [CouponsService, ReferralsService],
|
||||||
exports: [CouponsService, ReferralsService],
|
exports: [CouponsService, ReferralsService],
|
||||||
})
|
})
|
||||||
export class RewardsModule {}
|
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). غيابها = لا خصم. */
|
/** نسبة خصم مطابقة الوجهة (docs/17 — B8). غيابها = لا خصم. */
|
||||||
destination_match_discount_pct?: number;
|
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);
|
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;
|
waitingMin?: number;
|
||||||
at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية)
|
at?: Date; // لحظة الحساب (لاختيار النافذة الزمنية)
|
||||||
geofenceMultiplier?: number;
|
geofenceMultiplier?: number;
|
||||||
|
/** وزن الحمولة بالكيلوغرام — للشحنات والطرود فقط (L3). */
|
||||||
|
weightKg?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuoteBreakdown {
|
export interface QuoteBreakdown {
|
||||||
@@ -14,6 +16,7 @@ export interface QuoteBreakdown {
|
|||||||
distance: number;
|
distance: number;
|
||||||
time: number;
|
time: number;
|
||||||
waiting: number;
|
waiting: number;
|
||||||
|
weight: number;
|
||||||
bookingFee: number;
|
bookingFee: number;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
surgeMultiplier: number;
|
surgeMultiplier: number;
|
||||||
@@ -38,18 +41,21 @@ export class TariffEngine {
|
|||||||
|
|
||||||
if (def.mode === 'fixed_quote') {
|
if (def.mode === 'fixed_quote') {
|
||||||
// سعر ثابت متفَق عليه (خط ثابت / وجهة بسعر معلن): لا مسافة ولا زمن.
|
// سعر ثابت متفَق عليه (خط ثابت / وجهة بسعر معلن): لا مسافة ولا زمن.
|
||||||
// كان يسقط سابقاً إلى الفرع المتري فيُحسب بالعدّاد رغم اسمه — أي أن
|
|
||||||
// «السعر الثابت» لم يكن ثابتاً أبداً.
|
|
||||||
const fixed = Math.max(0, def.fixed_fare ?? 0);
|
const fixed = Math.max(0, def.fixed_fare ?? 0);
|
||||||
const waitingOnly = waitingMin * (win.per_min_waiting ?? 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 =
|
const multiplier =
|
||||||
(def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1) *
|
(def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1) *
|
||||||
(input.geofenceMultiplier ?? 1.0);
|
(input.geofenceMultiplier ?? 1.0);
|
||||||
const fixedTotal = TariffEngine.round(
|
const fixedTotal = TariffEngine.round(
|
||||||
(fixed + waitingOnly) * multiplier,
|
(fixed + waitingOnly + weightCharge) * multiplier,
|
||||||
def.rounding,
|
def.rounding,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -58,8 +64,9 @@ export class TariffEngine {
|
|||||||
distance: 0,
|
distance: 0,
|
||||||
time: 0,
|
time: 0,
|
||||||
waiting: TariffEngine.n(waitingOnly),
|
waiting: TariffEngine.n(waitingOnly),
|
||||||
|
weight: TariffEngine.n(weightCharge),
|
||||||
bookingFee: 0,
|
bookingFee: 0,
|
||||||
subtotal: TariffEngine.n(fixed + waitingOnly),
|
subtotal: TariffEngine.n(fixed + waitingOnly + weightCharge),
|
||||||
surgeMultiplier: multiplier,
|
surgeMultiplier: multiplier,
|
||||||
total: fixedTotal,
|
total: fixedTotal,
|
||||||
currency: def.currency,
|
currency: def.currency,
|
||||||
@@ -84,8 +91,15 @@ export class TariffEngine {
|
|||||||
const waitingCharge = waitingMin * (win.per_min_waiting ?? 0);
|
const waitingCharge = waitingMin * (win.per_min_waiting ?? 0);
|
||||||
const bookingFee = def.booking_fee ?? 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 =
|
let subtotal =
|
||||||
win.flag + distanceCharge + timeCharge + waitingCharge + bookingFee;
|
win.flag + distanceCharge + timeCharge + waitingCharge + weightCharge + bookingFee;
|
||||||
|
|
||||||
const surgeMultiplier =
|
const surgeMultiplier =
|
||||||
def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1;
|
def.surge?.enabled && def.surge.multiplier ? def.surge.multiplier : 1;
|
||||||
@@ -104,6 +118,7 @@ export class TariffEngine {
|
|||||||
distance: TariffEngine.n(distanceCharge),
|
distance: TariffEngine.n(distanceCharge),
|
||||||
time: TariffEngine.n(timeCharge),
|
time: TariffEngine.n(timeCharge),
|
||||||
waiting: TariffEngine.n(waitingCharge),
|
waiting: TariffEngine.n(waitingCharge),
|
||||||
|
weight: TariffEngine.n(weightCharge),
|
||||||
bookingFee,
|
bookingFee,
|
||||||
subtotal: TariffEngine.n(subtotal),
|
subtotal: TariffEngine.n(subtotal),
|
||||||
surgeMultiplier,
|
surgeMultiplier,
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Tariff } from './entities/tariff.entity';
|
import { Tariff } from './entities/tariff.entity';
|
||||||
|
import { SurgeState } from './entities/surge-state.entity';
|
||||||
import { TariffService } from './tariff.service';
|
import { TariffService } from './tariff.service';
|
||||||
|
import { SurgeService } from './surge.service';
|
||||||
import { TariffController } from './tariff.controller';
|
import { TariffController } from './tariff.controller';
|
||||||
|
import { AdminTariffController } from './admin-tariff.controller';
|
||||||
|
import { SurgeController } from './surge.controller';
|
||||||
import { TenantsModule } from '../tenants/tenants.module';
|
import { TenantsModule } from '../tenants/tenants.module';
|
||||||
|
import { Driver } from '../drivers/entities/driver.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Tariff]), TenantsModule],
|
imports: [TypeOrmModule.forFeature([Tariff, SurgeState, Driver]), TenantsModule],
|
||||||
controllers: [TariffController],
|
controllers: [TariffController, AdminTariffController, SurgeController],
|
||||||
providers: [TariffService],
|
providers: [TariffService, SurgeService],
|
||||||
exports: [TariffService],
|
exports: [TariffService, SurgeService],
|
||||||
})
|
})
|
||||||
export class TariffModule {}
|
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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Tariff } from './entities/tariff.entity';
|
import { Tariff } from './entities/tariff.entity';
|
||||||
@@ -37,10 +41,75 @@ export class TariffService {
|
|||||||
|
|
||||||
async create(data: Partial<Tariff>): Promise<Tariff> {
|
async create(data: Partial<Tariff>): Promise<Tariff> {
|
||||||
const saved = await this.repo.save(this.repo.create(data));
|
const saved = await this.repo.save(this.repo.create(data));
|
||||||
// تعرفة جديدة = النسخة الفعّالة تغيّرت → الكاش صار كذباً.
|
await this.invalidateCache(saved);
|
||||||
await this.cache.del(
|
return saved;
|
||||||
CacheKeys.tariff(saved.tenant_id, saved.city, saved.service_class),
|
}
|
||||||
);
|
|
||||||
|
/** كل تعرفات المستأجر (بما فيها المُعطّلة). */
|
||||||
|
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;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,4 +128,36 @@ export class TariffService {
|
|||||||
const quote = TariffEngine.quote(tariff.definition, input);
|
const quote = TariffEngine.quote(tariff.definition, input);
|
||||||
return { quote, tariffId: tariff.id, version: tariff.version };
|
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',
|
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,
|
TenantRevenueEntry,
|
||||||
TenantPendingEntry,
|
TenantPendingEntry,
|
||||||
} from './entities/tenant-ledger.entity';
|
} 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 { TenantWalletService } from './tenant-wallet.service';
|
||||||
import { TenantWalletController } from './tenant-wallet.controller';
|
import { TenantWalletController } from './tenant-wallet.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([TenantRevenueEntry, TenantPendingEntry])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
TenantRevenueEntry,
|
||||||
|
TenantPendingEntry,
|
||||||
|
Payment,
|
||||||
|
Payout,
|
||||||
|
Trip,
|
||||||
|
]),
|
||||||
|
],
|
||||||
controllers: [TenantWalletController],
|
controllers: [TenantWalletController],
|
||||||
providers: [TenantWalletService],
|
providers: [TenantWalletService],
|
||||||
exports: [TenantWalletService],
|
exports: [TenantWalletService],
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
TenantPendingEntry,
|
TenantPendingEntry,
|
||||||
LedgerReason,
|
LedgerReason,
|
||||||
} from './entities/tenant-ledger.entity';
|
} 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 { TenantWalletService } from './tenant-wallet.service';
|
||||||
|
|
||||||
const TENANT = '11111111-1111-1111-1111-111111111111';
|
const TENANT = '11111111-1111-1111-1111-111111111111';
|
||||||
@@ -49,6 +52,9 @@ describe('TenantWalletService — محفظتا المستأجر (docs/24)', () =
|
|||||||
wallet = new TenantWalletService(
|
wallet = new TenantWalletService(
|
||||||
ds.getRepository(TenantRevenueEntry),
|
ds.getRepository(TenantRevenueEntry),
|
||||||
ds.getRepository(TenantPendingEntry),
|
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,
|
TenantLedgerBase,
|
||||||
LedgerReason,
|
LedgerReason,
|
||||||
} from './entities/tenant-ledger.entity';
|
} 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';
|
export type Book = 'revenue' | 'pending';
|
||||||
@@ -36,6 +39,12 @@ export class TenantWalletService {
|
|||||||
private readonly revenue: Repository<TenantRevenueEntry>,
|
private readonly revenue: Repository<TenantRevenueEntry>,
|
||||||
@InjectRepository(TenantPendingEntry)
|
@InjectRepository(TenantPendingEntry)
|
||||||
private readonly pending: Repository<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;
|
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 { PlatformGuard } from '../../common/platform/platform.guard';
|
||||||
import { StorageService } from '../../common/storage/storage.service';
|
import { StorageService } from '../../common/storage/storage.service';
|
||||||
import { readImageMeta, validateLogo } from '../../common/storage/image-meta';
|
import { readImageMeta, validateLogo } from '../../common/storage/image-meta';
|
||||||
|
import { CatalogService } from '../catalog/catalog.service';
|
||||||
|
|
||||||
@ApiTags('tenants')
|
@ApiTags('tenants')
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -28,6 +29,7 @@ export class TenantsController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly tenants: TenantsService,
|
private readonly tenants: TenantsService,
|
||||||
private readonly storage: StorageService,
|
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);
|
return this.tenants.platformOverview(Number.isFinite(n) && n > 0 ? Math.min(n, 365) : 30);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** كتالوج الميزات ووسائل الدفع — تعرضهما لوحة السوبر-أدمن عند التزويد. */
|
/** كتالوج الميزات ووسائل الدفع — يقرأ من القاعدة (N6) مع fallback للكود الثابت. */
|
||||||
@ApiSecurity('x-platform-secret')
|
@ApiSecurity('x-platform-secret')
|
||||||
@UseGuards(PlatformGuard)
|
@UseGuards(PlatformGuard)
|
||||||
@Get('admin/features')
|
@Get('admin/features')
|
||||||
features() {
|
async features() {
|
||||||
return { features: FEATURES, payment_methods: PAYMENT_METHODS };
|
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')
|
@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 { Tariff } from '../tariff/entities/tariff.entity';
|
||||||
import { TenantsService } from './tenants.service';
|
import { TenantsService } from './tenants.service';
|
||||||
import { TenantsController } from './tenants.controller';
|
import { TenantsController } from './tenants.controller';
|
||||||
|
import { CatalogModule } from '../catalog/catalog.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
// Trip ودفتر الإيراد هنا **للقراءة فقط** — تقرير GMV والإيراد للسوبر-أدمن
|
// Trip ودفتر الإيراد هنا **للقراءة فقط** — تقرير GMV والإيراد للسوبر-أدمن
|
||||||
// (docs/22 — N1، docs/24 — P5). الكتابة في الدفتر تخصّ TenantWalletService وحده.
|
// (docs/22 — N1، docs/24 — P5). الكتابة في الدفتر تخصّ TenantWalletService وحده.
|
||||||
// `Tariff` هنا لزرع تعرفة الانطلاق عند التزويد. المستودع مباشرةً لا
|
// `Tariff` هنا لزرع تعرفة الانطلاق عند التزويد. المستودع مباشرةً لا
|
||||||
// `TariffModule` — تلك الوحدة تستورد هذه، فالاستيراد المتبادل دورة.
|
// `TariffModule` — تلك الوحدة تستورد هذه، فالاستيراد المتبادل دورة.
|
||||||
imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry, Tariff])],
|
imports: [TypeOrmModule.forFeature([Tenant, Trip, TenantRevenueEntry, Tariff]), CatalogModule],
|
||||||
controllers: [TenantsController],
|
controllers: [TenantsController],
|
||||||
providers: [TenantsService],
|
providers: [TenantsService],
|
||||||
exports: [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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
import { TripsService, RequestTripDto } from './trips.service';
|
import { TripsService, RequestTripDto } from './trips.service';
|
||||||
|
import { TripAudioService } from './trip-audio.service';
|
||||||
import { TripStatus } from './entities/trip.entity';
|
import { TripStatus } from './entities/trip.entity';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
@@ -12,7 +14,10 @@ import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('trips')
|
@Controller('trips')
|
||||||
export class TripsController {
|
export class TripsController {
|
||||||
constructor(private readonly trips: TripsService) {}
|
constructor(
|
||||||
|
private readonly trips: TripsService,
|
||||||
|
private readonly audio: TripAudioService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** سرد رحلات المستأجر — للوحة الأدمن، بترشيح الحالة (docs/22 — N4). */
|
/** سرد رحلات المستأجر — للوحة الأدمن، بترشيح الحالة (docs/22 — N4). */
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@@ -70,4 +75,39 @@ export class TripsController {
|
|||||||
const actor = user.role === 'driver' ? 'driver' : 'rider';
|
const actor = user.role === 'driver' ? 'driver' : 'rider';
|
||||||
return this.trips.cancel(user.tenantId, id, actor, user.userId);
|
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 { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Trip } from './entities/trip.entity';
|
import { Trip } from './entities/trip.entity';
|
||||||
import { TripEvent } from './entities/trip-event.entity';
|
import { TripEvent } from './entities/trip-event.entity';
|
||||||
|
import { TripAudio } from './entities/trip-audio.entity';
|
||||||
import { TripsService } from './trips.service';
|
import { TripsService } from './trips.service';
|
||||||
import { ScheduledTripsSweeper } from './scheduled-trips.sweeper';
|
|
||||||
import { TripStateService } from './trip-state.service';
|
import { TripStateService } from './trip-state.service';
|
||||||
|
import { TripAudioService } from './trip-audio.service';
|
||||||
import { TripsController } from './trips.controller';
|
import { TripsController } from './trips.controller';
|
||||||
import { MapsModule } from '../maps/maps.module';
|
import { MapsModule } from '../maps/maps.module';
|
||||||
import { TariffModule } from '../tariff/tariff.module';
|
import { TariffModule } from '../tariff/tariff.module';
|
||||||
@@ -23,7 +24,7 @@ import { DriverTrack } from '../locations/entities/driver-track.entity';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Trip, TripEvent, DriverTrack]),
|
TypeOrmModule.forFeature([Trip, TripEvent, TripAudio, DriverTrack]),
|
||||||
MapsModule,
|
MapsModule,
|
||||||
TariffModule,
|
TariffModule,
|
||||||
MatchingModule,
|
MatchingModule,
|
||||||
@@ -38,7 +39,7 @@ import { DriverTrack } from '../locations/entities/driver-track.entity';
|
|||||||
RewardsModule,
|
RewardsModule,
|
||||||
],
|
],
|
||||||
controllers: [TripsController],
|
controllers: [TripsController],
|
||||||
providers: [TripsService, TripStateService, ScheduledTripsSweeper, TripDistanceService],
|
providers: [TripsService, TripStateService, TripDistanceService, TripAudioService],
|
||||||
exports: [TripsService],
|
exports: [TripsService],
|
||||||
})
|
})
|
||||||
export class TripsModule {}
|
export class TripsModule {}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ import { GeofenceService } from '../geofence/geofence.service';
|
|||||||
import { CouponsService } from '../rewards/coupons.service';
|
import { CouponsService } from '../rewards/coupons.service';
|
||||||
import { ReferralsService } from '../rewards/referrals.service';
|
import { ReferralsService } from '../rewards/referrals.service';
|
||||||
import { TripDistanceService } from './trip-distance.service';
|
import { TripDistanceService } from './trip-distance.service';
|
||||||
|
import { SurgeService } from '../tariff/surge.service';
|
||||||
|
import { TierCalculatorService } from '../drivers/tier-calculator.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TripsService {
|
export class TripsService {
|
||||||
@@ -92,6 +94,8 @@ export class TripsService {
|
|||||||
private readonly coupons: CouponsService,
|
private readonly coupons: CouponsService,
|
||||||
private readonly referrals: ReferralsService,
|
private readonly referrals: ReferralsService,
|
||||||
private readonly distances: TripDistanceService,
|
private readonly distances: TripDistanceService,
|
||||||
|
private readonly surge: SurgeService,
|
||||||
|
private readonly tierCalc: TierCalculatorService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get(tenantId: string, id: string): Promise<Trip | null> {
|
get(tenantId: string, id: string): Promise<Trip | null> {
|
||||||
@@ -317,6 +321,53 @@ export class TripsService {
|
|||||||
return released;
|
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). */
|
/** قبول سائق للرحلة (أول قبول يفوز — docs/17 A4). */
|
||||||
async accept(tenantId: string, tripId: string, driverUserId: string) {
|
async accept(tenantId: string, tripId: string, driverUserId: string) {
|
||||||
const driver = await this.drivers.findByUser(tenantId, driverUserId);
|
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): المبلغ ينتقل من
|
// تسوية المحفظة عند الدفع (payment_method === wallet): المبلغ ينتقل من
|
||||||
// الراكب للسائق **كاملاً**. العمولة لا تُقتطع هنا — خُصمت من الرصيد
|
// الراكب للسائق **كاملاً**. العمولة لا تُقتطع هنا — خُصمت من الرصيد
|
||||||
// التشغيلي عند الإنهاء (docs/18 §5.5: قاعدة واحدة للكاش والمحفظة).
|
// التشغيلي عند الإنهاء (docs/18 §5.5: قاعدة واحدة للكاش والمحفظة).
|
||||||
@@ -506,6 +568,20 @@ export class TripsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.notifyParties(tenantId, trip, toStatus, snapshot.driver_user_id);
|
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;
|
return trip;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +629,14 @@ export class TripsService {
|
|||||||
await this.cancelLosingOffers(tenantId, tripId, actorUserId);
|
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, {
|
await this.notifyParties(tenantId, trip, 'cancelled', snapshot.driver_user_id, {
|
||||||
fee,
|
fee,
|
||||||
currency: trip.currency ?? '',
|
currency: trip.currency ?? '',
|
||||||
|
|||||||
Reference in New Issue
Block a user