Files
tripz-llc/backend/src/modules/tariff/surge.service.ts
T

256 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}