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>
This commit is contained in:
Hamza-Ayed
2026-07-17 19:43:47 +03:00
co-authored by Claude Opus 4.8
parent 4e9d4d9be1
commit c2a4b9d1a6
13 changed files with 588 additions and 10 deletions
@@ -6,6 +6,7 @@ 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 {
@@ -63,6 +64,44 @@ export class DriversService {
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 },