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, @InjectRepository(Driver) private readonly driverRepo: Repository, private readonly tariffService: TariffService, private readonly config: ConfigService, ) {} /** * إعادة حساب المضاعفات لجميع الحالات النشطة. * ي refresh عرض السائقين من قاعدة البيانات قبل كل حساب. */ async recalculateAll(): Promise<{ updated: number; unchanged: number }> { const cooldownMs = this.config.get('trips.surgeCooldownMs') ?? 300_000; const sensitivity = this.config.get('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 { 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 { 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 { 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 { 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 { 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 { return this.repo.findOne({ where: { tenant_id: tenantId, city, service_class: serviceClass } }); } /** كل الحالات النشطة لمستأجر. */ async listStates(tenantId: string): Promise { return this.repo.find({ where: { tenant_id: tenantId }, order: { city: 'ASC', service_class: 'ASC' } }); } // ─── داخلي ──────────────────────────────────────────────────── /** * تحديث عدد السائقين المتاحين لكل حالة surge من قاعدة البيانات الفعلية * بدلاً من الاعتماد على عداد ثابت. يُستدعى قبل كل إعادة حساب. */ private async refreshSupplyFromDrivers(now: Date): Promise { 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 { 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; } }