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
+2
View File
@@ -39,6 +39,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
import { PaymentsModule } from './modules/payments/payments.module';
import { StorageModule } from './common/storage/storage.module';
import { DocumentsModule } from './modules/documents/documents.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { GeminiModule } from './integrations/gemini/gemini.module';
@Module({
@@ -100,6 +101,7 @@ import { GeminiModule } from './integrations/gemini/gemini.module';
ChatModule,
RatingsModule,
DocumentsModule,
VehiclesModule,
SeedModule,
],
providers: [
@@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* بيانات السائق والمركبة (docs/17 — المجموعة C):
* - جدول `vehicles` (مكافئ CarRegistration عند سيرو) — سائق قد يملك أكثر من مركبة.
* - حقول الملف الشخصي للسائق: الرقم الوطني (مشفّر + فهرس أعمى)، الرخصة،
* ai_data/user_input، سبب الرفض.
*/
export class VehiclesAndDriverProfile1721910000000 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE IF NOT EXISTS tripz_vehicles (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL,
driver_id uuid NOT NULL,
plate varchar,
vin varchar,
make varchar,
model varchar,
year integer,
color varchar,
color_hex varchar,
fuel varchar,
owner varchar,
registration_expiry date,
category varchar,
is_default boolean NOT NULL DEFAULT false,
status varchar NOT NULL DEFAULT 'pending',
ai_data jsonb NOT NULL DEFAULT '{}',
user_input jsonb NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
)
`);
await q.query(
`CREATE INDEX IF NOT EXISTS "IDX_tripz_vehicles_tenant_driver" ON tripz_vehicles (tenant_id, driver_id)`,
);
const add = (col: string, type: string) =>
q.query(`ALTER TABLE tripz_drivers ADD COLUMN IF NOT EXISTS ${col} ${type}`);
await add('gender', 'varchar');
await add('national_number', 'varchar');
await add('national_number_bidx', 'varchar');
await add('name_arabic', 'varchar');
await add('birthdate', 'date');
await add('address', 'varchar');
await add('license_type', 'varchar');
await add('license_categories', 'varchar');
await add('license_issue', 'date');
await add('license_expiry', 'date');
await add('rejected_reason', 'varchar');
await add(`ai_data`, `jsonb NOT NULL DEFAULT '{}'`);
await add(`user_input`, `jsonb NOT NULL DEFAULT '{}'`);
// فهرس فريد على الرقم الوطني لكل مستأجر (عبر الفهرس الأعمى، لا العمود المشفّر
// — العشوائي لا يصلح للتفرّد). جزئي: يتجاهل السائقين بلا رقم وطني بعد.
await q.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_tripz_drivers_tenant_national"
ON tripz_drivers (tenant_id, national_number_bidx)
WHERE national_number_bidx IS NOT NULL
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP INDEX IF EXISTS "UQ_tripz_drivers_tenant_national"`);
for (const c of [
'user_input', 'ai_data', 'rejected_reason', 'license_expiry', 'license_issue',
'license_categories', 'license_type', 'address', 'birthdate', 'name_arabic',
'national_number_bidx', 'national_number', 'gender',
]) {
await q.query(`ALTER TABLE tripz_drivers DROP COLUMN IF EXISTS ${c}`);
}
await q.query(`DROP TABLE IF EXISTS tripz_vehicles`);
}
}
@@ -18,7 +18,10 @@ export const REQUIRED_DOCS: Array<{
{ type: 'vehicle_registration', name_ar: 'دفتر/بطاقة المركبة', sides: ['front', 'back'] },
{ type: 'criminal_record', name_ar: 'عدم محكومية', sides: ['single'] },
{ type: 'selfie', name_ar: 'صورة شخصية', sides: ['single'] },
{ type: 'vehicle_photo', name_ar: 'صور السيارة', sides: ['single'], min: 1 },
// صورتان للسيارة على الأقل (docs/17 — C4): أمامية وخلفية/جانبية.
{ type: 'vehicle_photo', name_ar: 'صور السيارة', sides: ['single'], min: 2 },
// فيديو حيوية الوجه (docs/17 — C5) — يُثبت أن الوجه حيّ لا صورة ثابتة.
{ type: 'face_liveness', name_ar: 'فيديو تأكيد الوجه', sides: ['single'] },
];
export interface DocFields {
@@ -15,7 +15,8 @@ export type DocType =
| 'insurance' // تأمين
| 'criminal_record' // عدم محكومية (البحث الجنائي)
| 'selfie' // صورة شخصية
| 'vehicle_photo'; // صورة/صور السيارة
| 'vehicle_photo' // صورة/صور السيارة
| 'face_liveness'; // فيديو حيوية الوجه (docs/17 — C5؛ غير السيلفي الثابت)
export type DocSide = 'front' | 'back' | 'single';
export type DocStatus = 'pending' | 'approved' | 'rejected';
@@ -21,6 +21,27 @@ export class DriversController {
return this.drivers.findByUser(user.tenantId, user.userId);
}
/** السائق يكمل ملفه (docs/17 — C2/C3): الرقم الوطني والرخصة والبيانات. */
@Patch('profile')
profile(@CurrentUser() user: AuthUser, @Body() body: any) {
return this.drivers.setProfile(user.tenantId, user.userId, {
gender: body.gender,
national_number: body.national_number,
name_arabic: body.name_arabic,
birthdate: body.birthdate,
address: body.address,
license_type: body.license_type,
license_categories: body.license_categories,
license_issue: body.license_issue,
license_expiry: body.license_expiry,
user_input: {
national_number: body.national_number,
name_arabic: body.name_arabic,
birthdate: body.birthdate,
},
});
}
@Patch('status')
setStatus(@CurrentUser() user: AuthUser, @Body('online') online: boolean) {
return this.drivers.setOnline(user.tenantId, user.userId, !!online);
@@ -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 },
@@ -75,6 +75,51 @@ export class Driver {
@Column({ type: 'timestamptz', nullable: true })
loc_updated_at: Date | null;
// ---- الملف الشخصي للسائق (docs/17 — C2) ----
@Column({ type: 'varchar', nullable: true })
gender: string | null; // male | female
// الرقم الوطني — معرِّف فريد حساس: مشفَّر at-rest + فهرس أعمى للتفرّد والبحث
// (نفس نمط الهاتف — docs/16). لا يُبحث في `national_number` مباشرة.
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
national_number: string | null;
@Column({ type: 'varchar', nullable: true })
national_number_bidx: string | null;
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
name_arabic: string | null;
@Column({ type: 'date', nullable: true })
birthdate: string | null;
@Column({ type: 'varchar', nullable: true })
address: string | null;
// ---- الرخصة ----
@Column({ type: 'varchar', nullable: true })
license_type: string | null;
@Column({ type: 'varchar', nullable: true })
license_categories: string | null;
@Column({ type: 'date', nullable: true })
license_issue: string | null;
@Column({ type: 'date', nullable: true })
license_expiry: string | null;
// سبب رفض خدمة العملاء (docs/17 — C2). يُعرض للسائق ليصحّح.
@Column({ type: 'varchar', nullable: true })
rejected_reason: string | null;
// مخرجات Gemini الخام مقابل ما أدخله السائق (docs/17 — C3) — للمقارنة والتدقيق.
@Column({ type: 'jsonb', default: {} })
ai_data: Record<string, any>;
@Column({ type: 'jsonb', default: {} })
user_input: Record<string, any>;
@CreateDateColumn()
created_at: Date;
@@ -0,0 +1,83 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { EncryptedTransformer } from '../../../common/crypto/crypto.util';
export type VehicleStatus = 'pending' | 'approved' | 'rejected';
/**
* مركبة السائق (مكافئ `CarRegistration` عند سيرو — docs/17 — C1).
* الجدول: tripz_vehicles. سائق قد يملك أكثر من مركبة (`is_default` يحدّد الفعّالة).
*/
@Entity('vehicles')
@Index(['tenant_id', 'driver_id'])
export class Vehicle {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
tenant_id: string;
@Column({ type: 'uuid' })
driver_id: string;
// ---- بيانات التسجيل ----
// اللوحة والـVIN معرِّفان حساسان — مشفَّران at-rest (docs/16).
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
plate: string | null;
@Column({ type: 'varchar', nullable: true, transformer: EncryptedTransformer })
vin: string | null;
@Column({ type: 'varchar', nullable: true })
make: string | null; // الصانع (Toyota…)
@Column({ type: 'varchar', nullable: true })
model: string | null;
@Column({ type: 'int', nullable: true })
year: number | null;
// اللون النصّي + الكود السداسي لتلوين السيارة في فلاتر (طلب المالك — C1).
@Column({ type: 'varchar', nullable: true })
color: string | null;
@Column({ type: 'varchar', nullable: true })
color_hex: string | null;
@Column({ type: 'varchar', nullable: true })
fuel: string | null; // petrol | diesel | electric | hybrid
@Column({ type: 'varchar', nullable: true })
owner: string | null; // اسم المالك إن اختلف عن السائق
@Column({ type: 'date', nullable: true })
registration_expiry: string | null;
@Column({ type: 'varchar', nullable: true })
category: string | null; // فئة المركبة (economy/comfort/van…)
@Column({ default: false })
is_default: boolean;
@Column({ type: 'varchar', default: 'pending' })
status: VehicleStatus;
// مخرجات Gemini الخام مقابل ما أدخله السائق (docs/17 — C3) — للمقارنة والتدقيق.
@Column({ type: 'jsonb', default: {} })
ai_data: Record<string, any>;
@Column({ type: 'jsonb', default: {} })
user_input: Record<string, any>;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,90 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { VehiclesService } from './vehicles.service';
import { DriversService } from '../drivers/drivers.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser, AuthUser } from '../auth/decorators/current-user.decorator';
@ApiTags('vehicles')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('vehicles')
export class VehiclesController {
constructor(
private readonly vehicles: VehiclesService,
private readonly drivers: DriversService,
) {}
/** يحوّل المستخدم المصادَق إلى سجل السائق، ويرفض غير السائقين. */
private async driverIdOf(user: AuthUser): Promise<string> {
const driver = await this.drivers.findByUser(user.tenantId, user.userId);
if (!driver) throw new ForbiddenException('Not a driver');
return driver.id;
}
@Get('mine')
async mine(@CurrentUser() user: AuthUser) {
return this.vehicles.list(user.tenantId, await this.driverIdOf(user));
}
@Post()
async create(@CurrentUser() user: AuthUser, @Body() body: any) {
const driverId = await this.driverIdOf(user);
// ما يُدخله السائق يُحفظ في user_input للمقارنة مع مخرجات Gemini (C3).
return this.vehicles.create(user.tenantId, driverId, {
plate: body.plate,
vin: body.vin,
make: body.make,
model: body.model,
year: body.year != null ? Number(body.year) : null,
color: body.color,
color_hex: body.color_hex,
fuel: body.fuel,
owner: body.owner,
registration_expiry: body.registration_expiry,
category: body.category,
user_input: {
plate: body.plate,
make: body.make,
model: body.model,
year: body.year,
color: body.color,
},
});
}
@Patch(':id/default')
async setDefault(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.vehicles.setDefault(user.tenantId, await this.driverIdOf(user), id);
}
@Delete(':id')
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.vehicles.remove(user.tenantId, await this.driverIdOf(user), id);
return { ok: true };
}
// ---- الأدمن يعتمد/يرفض المركبة ----
@UseGuards(RolesGuard)
@Roles('admin', 'dispatcher')
@Patch(':id/status')
async setStatus(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body('status') status: 'approved' | 'rejected' | 'pending',
) {
return this.vehicles.setStatus(user.tenantId, id, status);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Vehicle } from './entities/vehicle.entity';
import { VehiclesService } from './vehicles.service';
import { VehiclesController } from './vehicles.controller';
import { DriversModule } from '../drivers/drivers.module';
@Module({
imports: [TypeOrmModule.forFeature([Vehicle]), DriversModule],
controllers: [VehiclesController],
providers: [VehiclesService],
exports: [VehiclesService],
})
export class VehiclesModule {}
@@ -0,0 +1,105 @@
import { randomUUID } from 'crypto';
import { newDb } from 'pg-mem';
import { DataSource } from 'typeorm';
import { Vehicle } from './entities/vehicle.entity';
import { VehiclesService } from './vehicles.service';
const TENANT = '11111111-1111-1111-1111-111111111111';
const DRIVER = '22222222-2222-2222-2222-222222222222';
describe('VehiclesService (docs/17 — C1)', () => {
let ds: DataSource;
let svc: VehiclesService;
beforeEach(async () => {
const db = newDb({ autoCreateForeignKeyIndices: true });
db.public.registerFunction({ name: 'version', returns: 'text' as any, implementation: () => 'pg-mem' });
db.public.registerFunction({
name: 'current_database',
returns: 'text' as any,
implementation: () => 'tripz',
});
db.registerExtension('uuid-ossp', (schema) =>
schema.registerFunction({
name: 'uuid_generate_v4',
returns: 'uuid' as any,
implementation: () => randomUUID(),
impure: true,
}),
);
await db.public.none(`CREATE EXTENSION "uuid-ossp"`);
ds = (await db.adapters.createTypeormDataSource({
type: 'postgres',
entities: [Vehicle],
entityPrefix: 'tripz_',
})) as DataSource;
await ds.initialize();
await ds.synchronize();
svc = new VehiclesService(ds.getRepository(Vehicle));
});
afterEach(async () => {
if (ds?.isInitialized) await ds.destroy();
});
it('أول مركبة تصير الافتراضية تلقائياً', async () => {
const v = await svc.create(TENANT, DRIVER, { make: 'Toyota', model: 'Corolla' });
expect(v.is_default).toBe(true);
expect(v.status).toBe('pending');
});
it('المركبة الثانية ليست افتراضية إلا بطلب صريح', async () => {
await svc.create(TENANT, DRIVER, { make: 'Toyota' });
const second = await svc.create(TENANT, DRIVER, { make: 'Kia' });
expect(second.is_default).toBe(false);
});
it('تعيين افتراضي جديد يلغي القديم — واحدة افتراضية فقط دائماً', async () => {
const first = await svc.create(TENANT, DRIVER, { make: 'Toyota' });
const second = await svc.create(TENANT, DRIVER, { make: 'Kia' });
await svc.setDefault(TENANT, DRIVER, second.id);
const list = await svc.list(TENANT, DRIVER);
const defaults = list.filter((v) => v.is_default);
expect(defaults).toHaveLength(1);
expect(defaults[0].id).toBe(second.id);
expect((await svc.getDefault(TENANT, DRIVER))!.id).toBe(second.id);
});
it('حذف الافتراضية يرقّي مركبة أخرى — لا يبقى السائق بلا افتراضية', async () => {
const first = await svc.create(TENANT, DRIVER, { make: 'Toyota' });
await svc.create(TENANT, DRIVER, { make: 'Kia' });
await svc.remove(TENANT, DRIVER, first.id);
const def = await svc.getDefault(TENANT, DRIVER);
expect(def).not.toBeNull();
expect(def!.is_default).toBe(true);
});
it('اللوحة والـVIN مشفَّران at-rest، والباقي واضح', async () => {
const v = await svc.create(TENANT, DRIVER, {
plate: 'ABC-123',
vin: '1HGCM82633A004352',
make: 'Honda',
color_hex: '#FF0000',
});
const raw = await ds.query(`SELECT plate, vin, make, color_hex FROM tripz_vehicles WHERE id = $1`, [v.id]);
expect(raw[0].plate).toMatch(/^v1:/); // مشفّر
expect(raw[0].vin).toMatch(/^v1:/);
expect(raw[0].make).toBe('Honda'); // غير حسّاس — واضح
expect(raw[0].color_hex).toBe('#FF0000');
// لكنه يُقرأ مفكوكاً عبر الـORM
const read = await svc.list(TENANT, DRIVER);
expect(read[0].plate).toBe('ABC-123');
expect(read[0].vin).toBe('1HGCM82633A004352');
});
it('المستأجرون معزولون', async () => {
await svc.create(TENANT, DRIVER, { make: 'Toyota' });
expect(await svc.list('33333333-3333-3333-3333-333333333333', DRIVER)).toHaveLength(0);
});
});
@@ -0,0 +1,96 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Vehicle } from './entities/vehicle.entity';
@Injectable()
export class VehiclesService {
constructor(
@InjectRepository(Vehicle) private readonly repo: Repository<Vehicle>,
) {}
list(tenantId: string, driverId: string): Promise<Vehicle[]> {
return this.repo.find({
where: { tenant_id: tenantId, driver_id: driverId },
order: { is_default: 'DESC', created_at: 'ASC' },
});
}
async getDefault(tenantId: string, driverId: string): Promise<Vehicle | null> {
const found = await this.repo.findOne({
where: { tenant_id: tenantId, driver_id: driverId, is_default: true },
});
// لو لم يُعلَّم افتراضي صراحةً، أوّل مركبة هي الفعّالة.
return found ?? this.repo.findOne({
where: { tenant_id: tenantId, driver_id: driverId },
order: { created_at: 'ASC' },
});
}
async create(tenantId: string, driverId: string, data: Partial<Vehicle>): Promise<Vehicle> {
const count = await this.repo.count({ where: { tenant_id: tenantId, driver_id: driverId } });
const vehicle = this.repo.create({
...data,
tenant_id: tenantId,
driver_id: driverId,
// أول مركبة للسائق هي الافتراضية تلقائياً.
is_default: data.is_default ?? count === 0,
status: 'pending',
});
const saved = await this.repo.save(vehicle);
if (saved.is_default) await this.clearOtherDefaults(tenantId, driverId, saved.id);
return saved;
}
async setDefault(tenantId: string, driverId: string, vehicleId: string): Promise<Vehicle> {
const v = await this.repo.findOne({
where: { tenant_id: tenantId, driver_id: driverId, id: vehicleId },
});
if (!v) throw new NotFoundException('vehicle not found');
v.is_default = true;
const saved = await this.repo.save(v);
await this.clearOtherDefaults(tenantId, driverId, vehicleId);
return saved;
}
/** الأدمن يعتمد/يرفض المركبة. */
async setStatus(tenantId: string, vehicleId: string, status: Vehicle['status']): Promise<Vehicle> {
const v = await this.repo.findOne({ where: { tenant_id: tenantId, id: vehicleId } });
if (!v) throw new NotFoundException('vehicle not found');
v.status = status;
return this.repo.save(v);
}
async remove(tenantId: string, driverId: string, vehicleId: string): Promise<void> {
const v = await this.repo.findOne({
where: { tenant_id: tenantId, driver_id: driverId, id: vehicleId },
});
if (!v) throw new NotFoundException('vehicle not found');
// لا نترك السائق بلا مركبة افتراضية إن كانت هذه الوحيدة الافتراضية —
// نرقّي أقدم مركبة أخرى.
await this.repo.remove(v);
if (v.is_default) {
const next = await this.repo.findOne({
where: { tenant_id: tenantId, driver_id: driverId },
order: { created_at: 'ASC' },
});
if (next) {
next.is_default = true;
await this.repo.save(next);
}
}
}
private async clearOtherDefaults(tenantId: string, driverId: string, keepId: string): Promise<void> {
await this.repo
.createQueryBuilder()
.update(Vehicle)
.set({ is_default: false })
.where('tenant_id = :tenantId AND driver_id = :driverId AND id != :keepId', {
tenantId,
driverId,
keepId,
})
.execute();
}
}