Update backend to use firebase-admin SDK for FCM
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user