256 lines
9.3 KiB
TypeScript
256 lines
9.3 KiB
TypeScript
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;
|
||
}
|
||
}
|