C1: جدول vehicles (مكافئ CarRegistration عند سيرو) — plate/vin مشفَّران، make/model/year، color+color_hex (لتلوين السيارة في فلاتر)، fuel/owner/ category، is_default، status. سائق قد يملك أكثر من مركبة؛ VehiclesService يضمن افتراضية واحدة دائماً (أول مركبة تلقائياً، حذف الافتراضية يرقّي غيرها). C2: حقول الملف على drivers — gender، national_number (مشفَّر + فهرس أعمى فريد لكل مستأجر، نفس نمط الهاتف)، name_arabic (مشفَّر)، birthdate، address، الرخصة (type/categories/issue/expiry)، rejected_reason. عبر PATCH /drivers/profile. C3: ai_data + user_input (jsonb) على drivers و vehicles — مخرجات Gemini مقابل مدخلات السائق، تُراكَم للمقارنة حقلاً بحقل. C4: vehicle_photo min:2 في كتالوج الوثائق. C5: نوع وثيقة face_liveness (فيديو) — الرفع يقبله بلا قيد mime. قرار: المركبة كيان مستقل لا حقول مسطّحة (سيرو يفصلها بـisDefault). الحقول القديمة على drivers تبقى للتوافق؛ المصدر الجديد vehicles. هجرة: VehiclesAndDriverProfile (جدول vehicles + أعمدة السائق + فهرس فريد جزئي على الرقم الوطني). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
179 lines
7.5 KiB
TypeScript
179 lines
7.5 KiB
TypeScript
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { Driver } from './entities/driver.entity';
|
|
import { UsersService } from '../users/users.service';
|
|
import { DriverLocationService } from '../locations/driver-location.service';
|
|
import { DriverCreditService } from '../credit/driver-credit.service';
|
|
import { EntitlementsService } from '../../common/entitlements/entitlements.service';
|
|
import { blindIndex } from '../../common/crypto/crypto.util';
|
|
|
|
@Injectable()
|
|
export class DriversService {
|
|
private readonly logger = new Logger('Drivers');
|
|
|
|
constructor(
|
|
@InjectRepository(Driver)
|
|
private readonly repo: Repository<Driver>,
|
|
private readonly users: UsersService,
|
|
private readonly locations: DriverLocationService,
|
|
private readonly credit: DriverCreditService,
|
|
private readonly entitlements: EntitlementsService,
|
|
) {}
|
|
|
|
findByUser(tenantId: string, userId: string): Promise<Driver | null> {
|
|
return this.repo.findOne({ where: { tenant_id: tenantId, user_id: userId } });
|
|
}
|
|
|
|
findById(tenantId: string, id: string): Promise<Driver | null> {
|
|
return this.repo.findOne({ where: { tenant_id: tenantId, id } });
|
|
}
|
|
|
|
/** تسجيل السائق بالوثائق — ينشئ سجل driver ويرفع دور المستخدم إلى driver. */
|
|
async apply(
|
|
tenantId: string,
|
|
userId: string,
|
|
data: Partial<Driver>,
|
|
): Promise<Driver> {
|
|
let driver = await this.findByUser(tenantId, userId);
|
|
if (!driver) {
|
|
// حدّ الباقة يُفرض على السيرفر (docs/19 — K4): السائق رقم 501 يُرفض
|
|
// مهما قال التطبيق. يُفحص عند الإنشاء فقط — سائق قائم لا يُطرد بتغيير باقة.
|
|
const max = await this.entitlements.limit(tenantId, 'drivers_max');
|
|
if (max != null) {
|
|
const count = await this.repo.count({ where: { tenant_id: tenantId } });
|
|
if (count >= max) throw new ForbiddenException('drivers_limit_reached');
|
|
}
|
|
}
|
|
if (!driver) {
|
|
driver = this.repo.create({
|
|
tenant_id: tenantId,
|
|
user_id: userId,
|
|
service_class: data.service_class ?? 'economy',
|
|
vehicle_make: data.vehicle_make,
|
|
vehicle_model: data.vehicle_model,
|
|
vehicle_plate: data.vehicle_plate,
|
|
vehicle_color: data.vehicle_color,
|
|
docs: data.docs ?? {},
|
|
verification_status: 'pending',
|
|
});
|
|
driver = await this.repo.save(driver);
|
|
}
|
|
// يصير دور المستخدم "driver" (يظهر في التوكن عند إعادة الدخول).
|
|
await this.users.setRole(tenantId, userId, 'driver');
|
|
return driver;
|
|
}
|
|
|
|
/**
|
|
* تحديث الملف الشخصي للسائق (docs/17 — C2/C3). الرقم الوطني يُخزَّن مشفّراً
|
|
* مع فهرس أعمى للتفرّد (نفس نمط الهاتف — docs/16)، وما يُدخله السائق يُحفظ
|
|
* في `user_input` للمقارنة لاحقاً بمخرجات Gemini.
|
|
*/
|
|
async setProfile(
|
|
tenantId: string,
|
|
userId: string,
|
|
data: Partial<Driver> & { national_number?: string },
|
|
): Promise<Driver> {
|
|
const driver = await this.findByUser(tenantId, userId);
|
|
if (!driver) throw new NotFoundException('Driver profile not found');
|
|
|
|
const patch: Partial<Driver> = {};
|
|
for (const f of [
|
|
'gender', 'name_arabic', 'birthdate', 'address',
|
|
'license_type', 'license_categories', 'license_issue', 'license_expiry',
|
|
] as const) {
|
|
if (data[f] !== undefined) (patch as any)[f] = data[f];
|
|
}
|
|
if (data.national_number !== undefined) {
|
|
patch.national_number = data.national_number;
|
|
patch.national_number_bidx = data.national_number ? blindIndex(data.national_number) : null;
|
|
}
|
|
// نراكم مدخلات السائق لا نستبدلها — للمقارنة مع Gemini حقلاً بحقل.
|
|
patch.user_input = { ...(driver.user_input ?? {}), ...(data.user_input ?? {}) };
|
|
|
|
await this.repo.update({ tenant_id: tenantId, id: driver.id }, patch);
|
|
return (await this.findById(tenantId, driver.id))!;
|
|
}
|
|
|
|
/** بحث بالرقم الوطني عبر الفهرس الأعمى (لا بالعمود المشفّر). */
|
|
findByNationalNumber(tenantId: string, nationalNumber: string): Promise<Driver | null> {
|
|
return this.repo.findOne({
|
|
where: { tenant_id: tenantId, national_number_bidx: blindIndex(nationalNumber) },
|
|
});
|
|
}
|
|
|
|
async setRating(tenantId: string, driverId: string, rating: number): Promise<void> {
|
|
await this.repo.update(
|
|
{ tenant_id: tenantId, id: driverId },
|
|
{ rating: Number(rating.toFixed(2)) },
|
|
);
|
|
}
|
|
|
|
async approve(tenantId: string, driverId: string): Promise<Driver> {
|
|
const driver = await this.findById(tenantId, driverId);
|
|
if (!driver) throw new NotFoundException('Driver not found');
|
|
driver.verification_status = 'approved';
|
|
const saved = await this.repo.save(driver);
|
|
// مكافأة التسجيل عند الاعتماد لا عند التقديم — حتى لا يحصدها من رُفض
|
|
// (docs/18 — J6). آمنة للتكرار: القاعدة تضمن مرة واحدة لكل سائق.
|
|
await this.credit.grantSignupBonus(tenantId, driver.id).catch((e: any) =>
|
|
this.logger.warn(`signup bonus failed for ${driver.id}: ${e?.message}`),
|
|
);
|
|
return saved;
|
|
}
|
|
|
|
/** يبدّل حالة الاتصال؛ عند الاتصال يدخل فهرس المتاحين، وعند الفصل يُزال. */
|
|
async setOnline(
|
|
tenantId: string,
|
|
userId: string,
|
|
online: boolean,
|
|
): Promise<Driver> {
|
|
const driver = await this.findByUser(tenantId, userId);
|
|
if (!driver) throw new NotFoundException('Driver profile not found');
|
|
// الدين تجاوز الأرضية → لا يتصل أصلاً؛ وإلا عُرضت عليه رحلات يُرفض قبولها
|
|
// فيبدو النظام معطوباً بدل أن يفهم أن عليه الشحن (docs/18 — J4).
|
|
if (online && (await this.credit.isBlocked(tenantId, driver.id))) {
|
|
throw new ForbiddenException('credit_exhausted');
|
|
}
|
|
driver.is_online = online;
|
|
await this.repo.save(driver);
|
|
await this.locations.setAvailability(
|
|
tenantId,
|
|
driver.id,
|
|
driver.service_class,
|
|
online ? 'available' : 'off',
|
|
);
|
|
return driver;
|
|
}
|
|
|
|
/**
|
|
* نبضة موقع — **Redis فقط** (docs/17 — H1). كانت تكتب صف السائق في Postgres
|
|
* على كل نبضة (كل ثانية/ثلاث لكل سائق متصل) — أثقل حمل كان على القاعدة.
|
|
* اللقطة الدائمة يكتبها الـworker دورياً.
|
|
*/
|
|
async updateLocation(
|
|
tenantId: string,
|
|
userId: string,
|
|
lat: number,
|
|
lng: number,
|
|
extra: { heading?: number; speed?: number } = {},
|
|
): Promise<{ ok: boolean; significant: boolean }> {
|
|
const driver = await this.findByUser(tenantId, userId);
|
|
if (!driver) throw new NotFoundException('Driver profile not found');
|
|
if (!driver.is_online) return { ok: true, significant: false };
|
|
|
|
const res = await this.locations.update(tenantId, driver.id, driver.service_class, {
|
|
lat,
|
|
lng,
|
|
heading: extra.heading,
|
|
speed: extra.speed,
|
|
});
|
|
return { ok: true, significant: res.significant };
|
|
}
|
|
|
|
/** الموقع الحيّ للسائق — من Redis، واللقطة احتياط. */
|
|
liveLocation(tenantId: string, driverId: string) {
|
|
return this.locations.get(tenantId, driverId);
|
|
}
|
|
}
|