Files
tripz-llc/backend/src/modules/drivers/drivers.service.ts
T
Hamza-AyedandClaude Opus 4.8 c2a4b9d1a6 feat: المجموعة C — بيانات السائق والمركبة (كيان مركبة + ملف السائق)
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>
2026-07-17 19:43:47 +03:00

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);
}
}